Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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"
44 changes: 44 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -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"
63 changes: 63 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.0.0"
}
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
39 changes: 39 additions & 0 deletions docs/releases.md
Original file line number Diff line number Diff line change
@@ -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 <validated-release-merge-commit> -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.
63 changes: 63 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -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"
}
13 changes: 13 additions & 0 deletions scripts/check-release.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
29 changes: 29 additions & 0 deletions scripts/release-notes.mjs
Original file line number Diff line number Diff line change
@@ -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));
}
34 changes: 34 additions & 0 deletions tests/release-metadata.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
Loading
Loading