diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 002ba25..f11fcde 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,19 +14,87 @@ on: type: boolean default: true +permissions: + contents: read + +concurrency: + group: ksefnik-docs + cancel-in-progress: false + +env: + IMAGE: ghcr.io/codeformers-it/ksefnik-docs + PLATFORM: linux/amd64 + jobs: build: + name: Build & push image runs-on: [self-hosted, codeformers] + timeout-minutes: 20 + permissions: + contents: read + packages: write + outputs: + sha: ${{ steps.meta.outputs.sha }} + steps: - uses: actions/checkout@v4 - - name: Build & push Docker image - run: ./scripts/build-and-push.sh + - id: meta + run: echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + run: echo '${{ secrets.GITHUB_TOKEN }}' | docker login ghcr.io -u '${{ github.actor }}' --password-stdin + + - name: Ensure buildx builder exists + run: | + docker buildx inspect ksefnik-builder >/dev/null 2>&1 \ + || docker buildx create --name ksefnik-builder --driver docker-container --bootstrap + + - name: Build & push + env: + PRO_REPO_TOKEN: ${{ secrets.PRO_REPO_TOKEN }} + run: | + BUILD_ARGS="" + if [ -n "$PRO_REPO_TOKEN" ]; then + BUILD_ARGS="--build-arg PRO_REPO_TOKEN=${PRO_REPO_TOKEN}" + fi + docker buildx build \ + --builder ksefnik-builder \ + --platform "${PLATFORM}" \ + --push \ + -f docker/docs.Dockerfile \ + -t "${IMAGE}:latest" \ + -t "${IMAGE}:${{ steps.meta.outputs.sha }}" \ + $BUILD_ARGS \ + . + + - name: Logout from GHCR + if: always() + run: docker logout ghcr.io || true deploy: + name: Deploy docs on VPS needs: build if: github.event_name == 'workflow_dispatch' && inputs.deploy - runs-on: [self-hosted, codeformers] + runs-on: [self-hosted, vps-codeformers] + timeout-minutes: 5 + steps: - - name: Deploy to VPS - run: ssh codeformers "cd /home/ubuntu/ksefnik.pl && ./scripts/deploy.sh" + - uses: actions/checkout@v4 + + - name: Sync compose + deploy script into deploy dir + run: | + set -e + DEPLOY_DIR=/home/ubuntu/ksefnik.pl + install -m 644 docker-compose.prod.yml "${DEPLOY_DIR}/docker-compose.prod.yml" + install -m 755 scripts/deploy.sh "${DEPLOY_DIR}/scripts/deploy.sh" + + - name: Run deploy.sh + run: /home/ubuntu/ksefnik.pl/scripts/deploy.sh + + - name: Smoke test + run: | + set -e + status=$(curl -sS -o /dev/null -w '%{http_code}' -m 10 http://127.0.0.1:7020/) + echo "http://127.0.0.1:7020/ -> HTTP ${status}" + [ "${status}" = "200" ] || exit 1 diff --git a/.github/workflows/guard-prod.yml b/.github/workflows/guard-prod.yml index 53a03cb..1fa4089 100644 --- a/.github/workflows/guard-prod.yml +++ b/.github/workflows/guard-prod.yml @@ -15,18 +15,24 @@ jobs: check: runs-on: ubuntu-latest steps: - - name: Check org membership + - name: Check author association env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AUTHOR: ${{ github.event.pull_request.user.login }} + ASSOCIATION: ${{ github.event.pull_request.author_association }} + PR: ${{ github.event.pull_request.number }} + # GitHub computes author_association on the event itself - no extra API call, + # no auth-as-bot edge cases. OWNER/MEMBER/COLLABORATOR all imply trusted. + # Anyone else (CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR, NONE) gets the PR closed. run: | - AUTHOR="${{ github.event.pull_request.user.login }}" - STATUS=$(gh api orgs/CodeFormers-it/members/$AUTHOR --silent -i 2>&1 | head -1 | grep -o '[0-9]\{3\}') - - if [ "$STATUS" != "204" ]; then - gh pr close ${{ github.event.pull_request.number }} \ - --repo ${{ github.repository }} \ - --comment "PRs to \`prod\` are restricted to org members. Please open your PR against \`main\` instead." - echo "Closed PR #${{ github.event.pull_request.number }} from external contributor $AUTHOR" - else - echo "$AUTHOR is an org member — PR allowed" - fi + case "$ASSOCIATION" in + OWNER|MEMBER|COLLABORATOR) + echo "$AUTHOR association=$ASSOCIATION - PR allowed" + ;; + *) + gh pr close "$PR" \ + --repo "${{ github.repository }}" \ + --comment "PRs to \`prod\` are restricted to org members. Please open your PR against \`main\` instead." + echo "Closed PR #$PR from $AUTHOR (association=$ASSOCIATION)" + ;; + esac diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 71157fd..b0e3eae 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -2,28 +2,53 @@ name: Release to npm (prod) # Merging into prod bumps the minor version of all @ksefnik/* packages, # builds everything, publishes to npm with provenance, tags the release, -# and opens a sync PR back to main. +# builds the docs Docker image, deploys it to the VPS, and opens a sync PR +# back to main. # # The merge to prod IS the "I want to release" decision. # Only @luke-cf can approve and merge PRs to prod. +# +# Pipeline shape (4 atomic jobs + sync-back): +# verify - install/build/typecheck/test on the current SHA; uploads dist artifacts. +# Safe to re-run - mutates nothing. +# publish-npm - downloads dist, bumps versions, publishes to npm, commits + tags + GH release. +# After this job the new vX.Y.Z tag exists on origin/prod. +# build-docs - checks out the v${VERSION} tag, builds the docs Docker image, pushes to GHCR. +# Re-runnable: same tag → same image content; tag overwrite is safe. +# deploy - pulls the new image on the VPS, recreates the container, smoke-tests. +# Re-runnable: just pulls latest again. +# sync-back - opens a PR prod → main so version bumps land back in main. on: push: branches: [prod] + workflow_dispatch: + inputs: + dry_run: + description: 'Verify only (no npm publish / git tag / docker push / deploy)' + type: boolean + default: true permissions: - contents: write - id-token: write - pull-requests: write + contents: read + +concurrency: + group: ksefnik-release + cancel-in-progress: false + +env: + IMAGE: ghcr.io/codeformers-it/ksefnik-docs + PLATFORM: linux/amd64 jobs: - release: + verify: + name: Verify (install / build / typecheck / test) if: github.actor != 'github-actions[bot]' runs-on: [self-hosted, codeformers] + timeout-minutes: 20 steps: - uses: actions/checkout@v4 with: - token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 - uses: pnpm/action-setup@v4 @@ -32,7 +57,6 @@ jobs: with: node-version: 22 cache: pnpm - registry-url: https://registry.npmjs.org - run: pnpm install --frozen-lockfile @@ -47,10 +71,61 @@ jobs: env: CI: 'true' + - name: Upload dist artifacts + # publish-npm consumes these instead of rebuilding from scratch. + uses: actions/upload-artifact@v4 + with: + name: packages-dist + path: packages/*/dist + if-no-files-found: error + retention-days: 1 + + publish-npm: + name: Publish to npm + tag release + needs: verify + # Skip when dispatched manually with dry_run=true. push:prod events have no + # 'inputs' object, so the check evaluates to false and the job runs as normal. + if: github.event_name != 'workflow_dispatch' || inputs.dry_run == false + runs-on: [self-hosted, codeformers] + timeout-minutes: 15 + permissions: + contents: write + id-token: write + outputs: + version: ${{ steps.tag.outputs.version }} + sha: ${{ steps.tag.outputs.sha }} + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + + # We need node_modules so pnpm publish can resolve workspace:* refs into + # concrete versions when it rewrites package.json files on publish. + - run: pnpm install --frozen-lockfile + + - name: Download dist artifacts + uses: actions/download-artifact@v4 + with: + name: packages-dist + path: packages + - name: Bump minor versions in all packages run: node scripts/bump-version.mjs minor - name: Publish to npm + # --no-git-checks lets pnpm publish from a dirty tree (we just bumped versions). + # If a partial publish fails mid-way (some packages on npm, some not), the next + # rerun will skip already-published versions thanks to npm's own dedup, but the + # version bump commit will not be in git yet - rerun is safe. run: pnpm -r publish --access public --provenance --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -69,6 +144,7 @@ jobs: git tag -f "v$VERSION" git push origin prod git push origin "v$VERSION" --force + echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" - name: Create GitHub Release uses: softprops/action-gh-release@v2 @@ -77,47 +153,101 @@ jobs: name: v${{ steps.tag.outputs.version }} generate_release_notes: true - - name: Build docs Docker image (tagged with npm version) + build-docs: + name: Build & push docs Docker image + needs: publish-npm + runs-on: [self-hosted, codeformers] + timeout-minutes: 20 + permissions: + contents: read + packages: write + steps: + - name: Checkout published tag + # Building from the exact tag publish-npm just pushed guarantees the docs image + # version matches the npm release. If this job is rerun, the same tag is checked + # out and the same image content is produced. + uses: actions/checkout@v4 + with: + ref: v${{ needs.publish-npm.outputs.version }} + + - name: Log in to GHCR + run: echo '${{ secrets.GITHUB_TOKEN }}' | docker login ghcr.io -u '${{ github.actor }}' --password-stdin + + - name: Ensure buildx builder exists run: | - VERSION="${{ steps.tag.outputs.version }}" - GIT_SHA=$(git rev-parse --short HEAD) - - DOCKER_CONFIG_DIR="$(mktemp -d)" - trap 'rm -rf "$DOCKER_CONFIG_DIR"' EXIT - AUTH_TOKEN=$(printf 'codeformers-it:%s' "${{ secrets.REPO_TOKEN_GITHUB_PAT }}" | base64) - printf '{"auths":{"ghcr.io":{"auth":"%s"}},"credsStore":""}' "$AUTH_TOKEN" > "$DOCKER_CONFIG_DIR/config.json" - if [ -d "$HOME/.docker/cli-plugins" ]; then - ln -sf "$HOME/.docker/cli-plugins" "$DOCKER_CONFIG_DIR/cli-plugins" - fi - export DOCKER_CONFIG="$DOCKER_CONFIG_DIR" + docker buildx inspect ksefnik-builder >/dev/null 2>&1 \ + || docker buildx create --name ksefnik-builder --driver docker-container --bootstrap + - name: Build & push + env: + PRO_REPO_TOKEN: ${{ secrets.PRO_REPO_TOKEN }} + run: | BUILD_ARGS="" - if [ -n "${PRO_REPO_TOKEN:-}" ]; then + if [ -n "$PRO_REPO_TOKEN" ]; then BUILD_ARGS="--build-arg PRO_REPO_TOKEN=${PRO_REPO_TOKEN}" fi - docker buildx build \ - --platform linux/amd64 \ + --builder ksefnik-builder \ + --platform "${PLATFORM}" \ --push \ -f docker/docs.Dockerfile \ - -t "ghcr.io/codeformers-it/ksefnik-docs:latest" \ - -t "ghcr.io/codeformers-it/ksefnik-docs:v${VERSION}" \ - -t "ghcr.io/codeformers-it/ksefnik-docs:${GIT_SHA}" \ + -t "${IMAGE}:latest" \ + -t "${IMAGE}:v${{ needs.publish-npm.outputs.version }}" \ + -t "${IMAGE}:${{ needs.publish-npm.outputs.sha }}" \ $BUILD_ARGS \ . - env: - PRO_REPO_TOKEN: ${{ secrets.PRO_REPO_TOKEN }} - - name: Deploy docs to VPS - run: ssh codeformers "cd /home/ubuntu/ksefnik.pl && ./scripts/deploy.sh" + - name: Logout from GHCR + if: always() + run: docker logout ghcr.io || true + + deploy: + name: Deploy docs on VPS + needs: [publish-npm, build-docs] + runs-on: [self-hosted, vps-codeformers] + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + ref: v${{ needs.publish-npm.outputs.version }} + + - name: Sync compose + deploy script into deploy dir + run: | + set -e + DEPLOY_DIR=/home/ubuntu/ksefnik.pl + install -m 644 docker-compose.prod.yml "${DEPLOY_DIR}/docker-compose.prod.yml" + install -m 755 scripts/deploy.sh "${DEPLOY_DIR}/scripts/deploy.sh" + + - name: Run deploy.sh + run: /home/ubuntu/ksefnik.pl/scripts/deploy.sh + + - name: Smoke test + run: | + set -e + status=$(curl -sS -o /dev/null -w '%{http_code}' -m 10 http://127.0.0.1:7020/) + echo "http://127.0.0.1:7020/ -> HTTP ${status}" + [ "${status}" = "200" ] || exit 1 + + sync-back: + name: Open sync PR prod → main + needs: publish-npm + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Open sync PR prod → main + - name: Create sync PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.publish-npm.outputs.version }} run: | gh pr create \ --base main \ --head prod \ - --title "chore: sync versions from prod v${{ steps.tag.outputs.version }}" \ - --body "Automated version sync after npm release of v${{ steps.tag.outputs.version }}. Merge to bring main in line with published versions." \ - || echo "sync PR already exists or no diff — skipping" + --title "chore: sync versions from prod v${VERSION}" \ + --body "Automated version sync after npm release of v${VERSION}. Merge to bring main in line with published versions." \ + || echo "sync PR already exists or no diff - skipping" diff --git a/packages/core/src/ksef/ksef.adapter.ts b/packages/core/src/ksef/ksef.adapter.ts index fbd5271..3f37638 100644 --- a/packages/core/src/ksef/ksef.adapter.ts +++ b/packages/core/src/ksef/ksef.adapter.ts @@ -52,6 +52,7 @@ export class KsefAdapterImpl implements KsefAdapter { subjectType, pageSize: opts.pageSize, pageOffset: opts.pageOffset, + includeXml: opts.includeXml, }) return result.invoices.map((raw) => ({ diff --git a/packages/core/src/ksef/types.ts b/packages/core/src/ksef/types.ts index 1e046f4..0130d67 100644 --- a/packages/core/src/ksef/types.ts +++ b/packages/core/src/ksef/types.ts @@ -38,6 +38,7 @@ export interface KsefClient { subjectType?: KsefSubjectType pageSize?: number pageOffset?: number + includeXml?: boolean }): Promise<{ invoices: KsefRawInvoice[]; total: number }> sendInvoice(params: { token: string diff --git a/packages/http/src/client.ts b/packages/http/src/client.ts index 757114f..ccc2e10 100644 --- a/packages/http/src/client.ts +++ b/packages/http/src/client.ts @@ -15,7 +15,7 @@ import { shouldRefresh, type ActiveSession, } from './session.js' -import { fetchInvoices as fetchInvoicesHttp } from './invoices.js' +import { fetchInvoices as fetchInvoicesHttp, fetchInvoiceXml as fetchInvoiceXmlHttp } from './invoices.js' import { fetchUpoXml } from './upo.js' import { KsefApiError } from './errors.js' import { fetchKsefTokenEncryptionKey } from './public-key.js' @@ -129,6 +129,7 @@ export class KsefHttpClient implements KsefClient { subjectType?: 'Subject1' | 'Subject2' | 'Subject3' pageSize?: number pageOffset?: number + includeXml?: boolean }): Promise<{ invoices: KsefRawInvoice[]; total: number }> { let session = decodeSessionToken(params.token) if (!session) { @@ -151,12 +152,31 @@ export class KsefHttpClient implements KsefClient { pageSize: params.pageSize, pageOffset: params.pageOffset, subjectType: params.subjectType ?? 'Subject2', + includeXml: params.includeXml, }), this.retryOpts, ) return result } + async fetchInvoiceXml(params: { token: string; ksefNumber: string }): Promise { + let session = decodeSessionToken(params.token) + if (!session) { + throw new Error('KsefHttpClient.fetchInvoiceXml: invalid session token') + } + if (shouldRefresh(session)) { + session = await withHttpRetry( + () => refreshAccessToken(this.http, session as ActiveSession), + this.retryOpts, + ) + } + return withHttpRetry( + () => + fetchInvoiceXmlHttp(this.http, (session as ActiveSession).accessToken, params.ksefNumber), + this.retryOpts, + ) + } + async sendInvoice(): Promise<{ ksefReferenceNumber: string; timestamp: string }> { throw new Error( 'KsefHttpClient.sendInvoice: not implemented in HTTP client MVP — see http_plan.md §H05.5', diff --git a/packages/mcp/src/tools/sync-invoices.ts b/packages/mcp/src/tools/sync-invoices.ts index 8f393c0..f529e6a 100644 --- a/packages/mcp/src/tools/sync-invoices.ts +++ b/packages/mcp/src/tools/sync-invoices.ts @@ -1,4 +1,6 @@ import type { Ksefnik } from '@ksefnik/core' +import { writeFile, mkdir } from 'node:fs/promises' +import { resolve, isAbsolute, join } from 'node:path' import { z } from 'zod' export const syncInvoicesSchema = z.object({ @@ -11,17 +13,48 @@ export const syncInvoicesSchema = z.object({ * Defaults to `cost` for backwards compatibility with the MVP. */ subject: z.enum(['sales', 'cost']).optional(), + /** + * When provided, downloads the full FA(2)/FA(3) XML for each invoice and + * saves it to `${saveDir}/${ksefReference}.xml`. Path must be absolute. + * Creates the directory if it doesn't exist. + */ + saveDir: z.string().optional(), }) export type SyncInvoicesInput = z.infer export async function syncInvoices(ksef: Ksefnik, input: SyncInvoicesInput) { const subjectType = input.subject === 'sales' ? 'Subject1' : 'Subject2' + const includeXml = Boolean(input.saveDir) + const invoices = await ksef.invoices.fetch({ from: input.dateFrom, to: input.dateTo, nip: input.nip, subjectType, + includeXml, }) - return { invoices, count: invoices.length, subject: input.subject ?? 'cost' } + + let savedFiles: string[] = [] + if (input.saveDir) { + const dir = isAbsolute(input.saveDir) ? input.saveDir : resolve(input.saveDir) + await mkdir(dir, { recursive: true }) + savedFiles = await Promise.all( + invoices + .filter((inv) => inv.ksefReference && inv.rawXml) + .map(async (inv) => { + const path = join(dir, `${inv.ksefReference}.xml`) + await writeFile(path, inv.rawXml ?? '', 'utf8') + return path + }), + ) + } + + return { + invoices, + count: invoices.length, + subject: input.subject ?? 'cost', + savedFiles: input.saveDir ? savedFiles : undefined, + savedCount: input.saveDir ? savedFiles.length : undefined, + } } diff --git a/packages/shared/src/types/ksef-adapter.ts b/packages/shared/src/types/ksef-adapter.ts index 4054d52..8b62868 100644 --- a/packages/shared/src/types/ksef-adapter.ts +++ b/packages/shared/src/types/ksef-adapter.ts @@ -14,6 +14,12 @@ export interface FetchInvoicesOpts { subjectType?: InvoiceSubjectType pageSize?: number pageOffset?: number + /** + * When true, also fetch the full FA(2)/FA(3) XML body for each invoice via + * `GET /invoices/ksef/{ksefNumber}` and put it on `Invoice.rawXml`. Off by + * default — metadata alone covers grossAmount/NIPs/dates. + */ + includeXml?: boolean } export interface SendInvoiceInput {