From d0f23e9c3f9fde4b7ef0801a9202d533efcd637b Mon Sep 17 00:00:00 2001 From: Foulla Date: Sat, 19 Sep 2026 19:06:37 +0100 Subject: [PATCH] feat(release): automate API changelogs and release preparation --- .github/workflows/ci.yml | 42 +++++++++++++++++++ .github/workflows/release-please.yml | 44 +++++++++++++++++++ .github/workflows/release.yml | 63 ++++++++++++++++++++++++++++ .release-please-manifest.json | 3 ++ CHANGELOG.md | 20 +++++++++ README.md | 2 + docs/releases.md | 39 +++++++++++++++++ release-please-config.json | 63 ++++++++++++++++++++++++++++ scripts/check-release.mjs | 13 ++++++ scripts/release-notes.mjs | 29 +++++++++++++ tests/release-metadata.test.mjs | 34 +++++++++++++++ tests/release-notes.test.mjs | 41 ++++++++++++++++++ version.txt | 1 + 13 files changed, 394 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .github/workflows/release.yml create mode 100644 .release-please-manifest.json create mode 100644 CHANGELOG.md create mode 100644 docs/releases.md create mode 100644 release-please-config.json create mode 100644 scripts/check-release.mjs create mode 100644 scripts/release-notes.mjs create mode 100644 tests/release-metadata.test.mjs create mode 100644 tests/release-notes.test.mjs create mode 100644 version.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5708f47 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + name: API checks + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + dotnet-version: '10.0.x' + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24' + - name: Check release tooling + run: | + node --test tests/*.test.mjs + node scripts/check-release.mjs + - name: Restore + run: dotnet restore Wida.slnx + - name: Build + run: dotnet build Wida.slnx --configuration Release --no-restore + - name: Test + run: dotnet test Wida.Tests/Wida.Tests.csproj --configuration Release --no-build --no-restore + - name: Publish API + run: dotnet publish Wida.Api/Wida.Api.csproj --configuration Release --no-build --no-restore --output "$RUNNER_TEMP/wida-api" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..882c46e --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,44 @@ +name: Release Please + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-please-main + cancel-in-progress: false + +jobs: + prepare: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + issues: write + pull-requests: write + actions: write + steps: + - name: Prepare release PR + id: release + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4 + with: + token: ${{ github.token }} + target-branch: main + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + skip-github-release: true + # GITHUB_TOKEN-created PRs do not trigger pull_request workflows. Explicit + # workflow_dispatch runs CI on the generated branch with read-only permissions. + - name: Check generated release PR + if: steps.release.outputs.prs_created == 'true' + env: + GH_TOKEN: ${{ github.token }} + # GitHub may evaluate env expressions even when the step is skipped. + # Release Please omits `pr` when no PR was created or updated. + RELEASE_BRANCH: ${{ fromJSON(steps.release.outputs.pr || '{}').headBranchName }} + run: gh workflow run ci.yml --repo "$GITHUB_REPOSITORY" --ref "$RELEASE_BRANCH" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e2e3ab8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + uses: ./.github/workflows/ci.yml + publish: + name: Publish GitHub Release + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + pull-requests: write + env: + RELEASE_TAG: ${{ github.ref_name }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + - name: Verify released commit belongs to main + run: git merge-base --is-ancestor HEAD origin/main + - name: Extract version-matched changelog + run: node scripts/release-notes.mjs "$RELEASE_TAG" > "$RUNNER_TEMP/release-notes.md" + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft > "$RUNNER_TEMP/existing-release.json" 2>/dev/null; then + # Only an already published release completes this step on a retry. + jq -e '.isDraft == false' "$RUNNER_TEMP/existing-release.json" > /dev/null + else + gh release create "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "Wida API $RELEASE_TAG" \ + --notes-file "$RUNNER_TEMP/release-notes.md" + fi + - name: Mark release PR as published + env: + GH_TOKEN: ${{ github.token }} + run: | + export RELEASE_COMMIT="$(git rev-parse HEAD)" + prs=$(gh pr list --repo "$GITHUB_REPOSITORY" --state merged --base main \ + --label "autorelease: pending" --limit 100 --json number,mergeCommit \ + --jq '.[] | select(.mergeCommit.oid == env.RELEASE_COMMIT) | .number') + for pr in $prs; do + gh pr edit "$pr" --repo "$GITHUB_REPOSITORY" --remove-label "autorelease: pending" + done diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7ce59af --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +User-visible API changes. API releases are versioned independently from the frontend. This repository has no previously published release history; `0.0.0` is only a bootstrap marker, not a historical release. + +## [Unreleased] + +### Added + +- Delete an owned document through `DELETE /api/documents/{id}`, including its invoice, invoice lines, processing runs and extracted fields. +- Return 404 for missing or other users' documents, and 409 while analysis is pending or running. Serialize deletion with upload and queue admission. + +### Maintenance + +- Prepare version and changelog updates through Release Please, and verify .NET builds, tests and publication output in CI. + +### Deployment + +- Document deletion requires no new migration or environment variable. Analysis credits already consumed are not refunded. +- Original-file deletion follows database commit; failed storage cleanup is logged for operator follow-up. +- The first release records changes since commit `36a04fd`, immediately before document deletion. It does not reconstruct older API history. diff --git a/README.md b/README.md index bf62da4..8d49f71 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Wida API +[Changelog](CHANGELOG.md) · [Release process](docs/releases.md) + Wida is an open-source ASP.NET Core API for uploading documents, recording invoices and line items, and tracking invoice analysis with Azure Document Intelligence. It targets .NET 10, stores application data in PostgreSQL through Entity Framework Core, and stores uploaded originals either locally or in private Supabase Storage. ## Documentation diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..521b21b --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,39 @@ +# API releases + +The API and frontend have independent versions and tags. Release Please uses the `simple` strategy to update `version.txt`, `.release-please-manifest.json` and `CHANGELOG.md`. `version.txt` is release metadata, not an override of .NET assembly versions or Docker image tags. + +## First release + +No API release has previously been published. `0.0.0` is an unpublished bootstrap marker; the first feature release is expected to be `0.1.0`. The bootstrap commit `36a04fdb52708c6a82cc627adeaf1e407272e379` excludes earlier history while including the already-deployed document deletion change. Do not tag `0.0.0` or invent historical release dates. The bootstrap SHA only applies before the first published release. + +## Automated preparation + +1. Allow GitHub Actions to create pull requests in this API repository's Settings → Actions → General. This setting is independent from the frontend repository. +2. Use Conventional Commit titles and squash-merge normal PRs. `feat:` increments the minor version; `fix:` increments the patch version. Breaking changes increment minor versions during `0.x`, and major versions after `1.0`. Hidden maintenance commits do not independently request a release. +3. On pushes to `main`, Release Please opens or updates a release PR. Manual retry is available through Actions → Release Please → Run workflow on `main`. +4. The workflow uses `GITHUB_TOKEN` and explicitly dispatches CI on the generated release branch because bot-created PRs do not automatically trigger PR workflows. No personal token or production secret is required. Empty PR outputs are handled safely. +5. Review the proposed version and notes. Move applicable `[Unreleased]` entries into the generated version section, remove duplicates and retain deployment caveats. Finish editorial changes after the last feature merge, as the bot can regenerate notes. + +CI checks release metadata, restores/builds the .NET 10 solution, runs the API tests and publishes the API into a temporary directory. PostgreSQL/RabbitMQ integration tests remain skipped unless their documented `WIDA_QUEUE_TEST_POSTGRES` and `WIDA_QUEUE_TEST_RABBITMQ` test-service environment variables are configured. CI never uses the production services. + +## Validate, tag and publish + +Release Please prepares PRs only (`skip-github-release: true`). After CI passes, merge the reviewed release PR under the repository's normal protections. Wait for main CI on that exact merge commit. + +```sh +git fetch origin main --tags +node scripts/check-release.mjs +node scripts/release-notes.mjs vX.Y.Z +git tag -a vX.Y.Z -m "Wida API vX.Y.Z" +git push origin vX.Y.Z +``` + +The tag workflow reruns CI, requires a commit on main, verifies the tag against `version.txt`, extracts only that version's dated changelog entry and publishes a GitHub Release. It then removes the matching release PR's `autorelease: pending` label so preparation can continue. The unpublished `0.0.0` marker has no dated notes and cannot pass publication. + +Keep published tags immutable. If publication or label cleanup fails, inspect logs and rerun: existing published releases are reused, while existing drafts require explicit attention. If tagging a later commit that supersedes an unpublished release PR, reconcile that older PR's pending label only after successful publication. + +## Deployment + +A GitHub Release does not itself deploy the API. Render's existing Git integration can deploy when main changes. Verify the deployed commit and `/healthz`, and perform appropriate authenticated smoke tests before claiming an endpoint works in production. Document skipped checks honestly. Deploy backward-compatible API changes before dependent frontend features. + +No migration, authentication configuration, production credentials or Render settings are changed by this release setup. Keep the previous successful Render deployment for rollback. Publish fixes under a new version instead of moving an existing tag. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..385f4a1 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "simple", + "include-component-in-tag": false, + "include-v-in-tag": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "changelog-path": "CHANGELOG.md", + "changelog-sections": [ + { + "type": "feat", + "section": "Added" + }, + { + "type": "fix", + "section": "Fixed" + }, + { + "type": "perf", + "section": "Performance" + }, + { + "type": "revert", + "section": "Reverted" + }, + { + "type": "docs", + "section": "Documentation", + "hidden": true + }, + { + "type": "chore", + "section": "Maintenance", + "hidden": true + }, + { + "type": "ci", + "section": "Maintenance", + "hidden": true + }, + { + "type": "refactor", + "section": "Maintenance", + "hidden": true + }, + { + "type": "test", + "section": "Maintenance", + "hidden": true + }, + { + "type": "style", + "section": "Maintenance", + "hidden": true + } + ], + "version-file": "version.txt" + } + }, + "bootstrap-sha": "36a04fdb52708c6a82cc627adeaf1e407272e379" +} diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs new file mode 100644 index 0000000..4782612 --- /dev/null +++ b/scripts/check-release.mjs @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { releaseNotes } from "./release-notes.mjs"; + +const version = readFileSync(new URL("../version.txt", import.meta.url), "utf8").trim(); +const manifest = JSON.parse(readFileSync(new URL("../.release-please-manifest.json", import.meta.url), "utf8")); +if (!/^\d+\.\d+\.\d+$/.test(version) || manifest["."] !== version) { + throw new Error("version.txt must contain a stable version matching the Release Please manifest."); +} +// 0.0.0 is an unpublished bootstrap marker, never an API release. +if (version !== "0.0.0") { + const changelog = readFileSync(new URL("../CHANGELOG.md", import.meta.url), "utf8"); + releaseNotes(`v${version}`, version, changelog); +} diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 0000000..0b003c9 --- /dev/null +++ b/scripts/release-notes.mjs @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Support both the historical Keep a Changelog headings and Release Please's +// linked headings (including level-three patch release headings). +function headingVersion(line) { + const heading = /^#{2,3} (?:\[v?(\d+\.\d+\.\d+)\](?:\([^\s)]+\))?|v?(\d+\.\d+\.\d+)) (?:- \d{4}-\d{2}-\d{2}|\(\d{4}-\d{2}-\d{2}\))$/.exec(line); + return heading?.[1] ?? heading?.[2]; +} + +export function releaseNotes(tag, version, changelog) { + if (!/^v\d+\.\d+\.\d+$/.test(tag) || tag !== `v${version}`) { + throw new Error("Release tag must match the stable version in version.txt."); + } + const lines = changelog.split(/\r?\n/); + const start = lines.findIndex(line => headingVersion(line) === version); + if (start < 0) throw new Error(`Missing dated changelog entry for ${version}.`); + const end = lines.findIndex((line, index) => index > start && (line.startsWith("## ") || headingVersion(line) !== undefined)); + const notes = lines.slice(start + 1, end < 0 ? undefined : end).join("\n").trim(); + if (!notes) throw new Error(`Empty changelog entry for ${version}.`); + return `${notes}\n`; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const version = readFileSync(new URL("../version.txt", import.meta.url), "utf8").trim(); + const changelog = readFileSync(new URL("../CHANGELOG.md", import.meta.url), "utf8"); + process.stdout.write(releaseNotes(process.argv[2], version, changelog)); +} diff --git a/tests/release-metadata.test.mjs b/tests/release-metadata.test.mjs new file mode 100644 index 0000000..e447568 --- /dev/null +++ b/tests/release-metadata.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { realpathSync, mkdtempSync, mkdirSync, copyFileSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +function fixture(t, version, manifestVersion, changelog = "") { + const root = realpathSync(mkdtempSync(join(tmpdir(), "wida-api-release-"))); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(join(root, "scripts")); + for (const name of ["release-notes.mjs", "check-release.mjs"]) { + copyFileSync(new URL(`../scripts/${name}`, import.meta.url), join(root, "scripts", name)); + } + writeFileSync(join(root, "version.txt"), version + "\n"); + writeFileSync(join(root, ".release-please-manifest.json"), JSON.stringify({ ".": manifestVersion })); + writeFileSync(join(root, "CHANGELOG.md"), changelog); + return (script, ...args) => spawnSync(process.execPath, [join(root, "scripts", script), ...args], { encoding: "utf8" }); +} + +test("bootstrap metadata passes CI but cannot be published", t => { + const run = fixture(t, "0.0.0", "0.0.0", "# Changelog\n\n## [Unreleased]\n"); + assert.equal(run("check-release.mjs").status, 0); + assert.notEqual(run("release-notes.mjs", "v0.0.0").status, 0); +}); + +test("metadata requires matching versions and dated notes once bootstrapped", t => { + assert.notEqual(fixture(t, "0.1.0", "0.0.0")("check-release.mjs").status, 0); + assert.notEqual(fixture(t, "invalid", "invalid")("check-release.mjs").status, 0); + assert.notEqual(fixture(t, "0.1.0", "0.1.0")("check-release.mjs").status, 0); + const run = fixture(t, "0.1.0", "0.1.0", "## [0.1.0](https://example.test/releases) (2026-09-19)\n\n- Document deletion.\n"); + assert.equal(run("check-release.mjs").status, 0); + assert.equal(run("release-notes.mjs", "v0.1.0").stdout, "- Document deletion.\n"); +}); diff --git a/tests/release-notes.test.mjs b/tests/release-notes.test.mjs new file mode 100644 index 0000000..4f0eea5 --- /dev/null +++ b/tests/release-notes.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { releaseNotes } from "../scripts/release-notes.mjs"; + +const changelog = "# Changelog\n\n## [Unreleased]\n\n- Future work\n\n## [0.1.1] - 2026-09-19\n\n### Fixed\n\n- Correct invoice export.\n\n## [0.1.0] - 2026-09-01\n\n- Previous version.\n"; + +test("release notes contain only the matching version, including CRLF files", () => { + for (const text of [changelog, changelog.replaceAll("\n", "\r\n")]) { + assert.equal(releaseNotes("v0.1.1", "0.1.1", text), "### Fixed\n\n- Correct invoice export.\n"); + } +}); + +test("release publication rejects mismatched tags and missing or empty entries", () => { + for (const tag of ["v0.1.0", "0.1.1", "v0.1.1-rc.1", undefined]) { + assert.throws(() => releaseNotes(tag, "0.1.1", changelog), /tag must match/); + } + assert.throws(() => releaseNotes("v0.1.2", "0.1.2", changelog), /Missing dated/); + assert.throws(() => releaseNotes("v0.1.1", "0.1.1", "## [0.1.1] - 2026-09-19\n\n## [0.1.0] - 2026-09-01\n- Old"), /Empty/); +}); + + +test("Release Please linked headings work without including adjacent releases", () => { + const notes = "# Changelog\n\n## [0.2.0](https://github.com/example/app/compare/v0.1.2...v0.2.0) (2026-09-21)\n\n### Added\n\n- Delete documents.\n\n### [0.1.2](https://github.com/example/app/compare/v0.1.1...v0.1.2) (2026-09-20)\n\n### Fixed\n\n- Fix preview.\n\n## [Unreleased]\n\n- Future work.\n\n## [0.1.1] - 2026-09-19\n\n- Older release.\n"; + assert.equal(releaseNotes("v0.2.0", "0.2.0", notes), "### Added\n\n- Delete documents.\n"); + assert.equal(releaseNotes("v0.1.2", "0.1.2", notes), "### Fixed\n\n- Fix preview.\n"); + assert.equal(releaseNotes("v0.1.1", "0.1.1", notes), "- Older release.\n"); +}); + +test("Release Please unlinked headings and CRLF retain the matching release only", () => { + const notes = "## 0.2.0 (2026-09-21)\r\n\r\n- New feature.\r\n\r\n### 0.1.2 (2026-09-20)\r\n\r\n- Fix.\r\n"; + assert.equal(releaseNotes("v0.2.0", "0.2.0", notes), "- New feature.\n"); +}); + +test("generated entries must have an exact version, date and nonempty body", () => { + for (const notes of [ + "## [0.1.10](https://example.test/compare) (2026-09-19)\n- Other version.", + "## [0.1.1](https://example.test/compare)\n- Missing date.", + "## [0.1.1](https://example.test/compare) (yesterday)\n- Bad date.", + ]) assert.throws(() => releaseNotes("v0.1.1", "0.1.1", notes), /Missing dated/); + assert.throws(() => releaseNotes("v0.1.2", "0.1.2", "### [0.1.2](https://example.test/compare) (2026-09-20)\n\n## [0.1.1] - 2026-09-19\n- Older release."), /Empty/); +}); diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..77d6f4c --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.0.0