diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d0a602..6cab661 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,38 +1,81 @@ name: Release on: - push: - tags: - - 'v*' + workflow_dispatch: + inputs: + version: + description: Release version in x.y.z format + required: true + type: string permissions: + actions: read contents: write id-token: write concurrency: - group: release-${{ github.ref_name }} + group: release-main cancel-in-progress: false jobs: release: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 + env: + RELEASE_VERSION: ${{ inputs.version }} steps: + - name: Create release app token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ttdash + - name: Check out repository uses: actions/checkout@v6 with: fetch-depth: 0 + ref: main + token: ${{ steps.app-token.outputs.token }} + persist-credentials: false - - name: Verify tagged commit is on main - run: | - git fetch origin main --depth=1 - git branch -r --contains HEAD | grep -q 'origin/main' - - - name: Verify tag matches package version + - name: Verify release version input run: | - VERSION="$(node -p "require('./package.json').version")" - test "v${VERSION}" = "${GITHUB_REF_NAME}" + export CURRENT_VERSION="$(node -p "require('./package.json').version")" + export RELEASE_VERSION + echo "CURRENT_VERSION=${CURRENT_VERSION}" >> "$GITHUB_ENV" + node - <<'NODE' + const current = process.env.CURRENT_VERSION + const requested = process.env.RELEASE_VERSION + const semverPattern = /^\d+\.\d+\.\d+$/ + + if (!semverPattern.test(requested)) { + throw new Error(`Release version must use x.y.z format. Received: ${requested}`) + } + + const parse = (value) => value.split('.').map((part) => Number.parseInt(part, 10)) + const compare = (left, right) => { + for (let index = 0; index < 3; index += 1) { + if (left[index] > right[index]) return 1 + if (left[index] < right[index]) return -1 + } + return 0 + } + + if (compare(parse(requested), parse(current)) < 0) { + throw new Error(`Release version ${requested} must not be lower than current package version ${current}`) + } + NODE + echo "RELEASE_TAG=v${RELEASE_VERSION}" >> "$GITHUB_ENV" + if [[ "${RELEASE_VERSION}" = "${CURRENT_VERSION}" ]]; then + echo "SHOULD_BUMP=false" >> "$GITHUB_ENV" + echo "Release version already present on main. Continuing in retry mode." + else + echo "SHOULD_BUMP=true" >> "$GITHUB_ENV" + fi - name: Set up Node.js uses: actions/setup-node@v6 @@ -41,6 +84,34 @@ jobs: cache: npm registry-url: https://registry.npmjs.org + - name: Verify main CI succeeded + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + MAIN_SHA="$(git rev-parse HEAD)" + node scripts/verify-main-ci.js \ + --repo "${{ github.repository }}" \ + --workflow ci.yml \ + --branch main \ + --sha "${MAIN_SHA}" \ + --retries 90 \ + --retry-delay-ms 10000 + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Configure authenticated remote + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + + - name: Bump package version + if: env.SHOULD_BUMP == 'true' + run: npm version "${RELEASE_VERSION}" --no-git-tag-version + - name: Install dependencies run: npm ci --ignore-scripts @@ -64,14 +135,49 @@ jobs: with: bun-version: latest + - name: Create release commit and tag + run: | + git fetch --tags origin + if [[ "${SHOULD_BUMP}" = "true" ]]; then + git add package.json package-lock.json + git commit -m "v${RELEASE_VERSION}: Release" + fi + + if git rev-parse "${RELEASE_TAG}" >/dev/null 2>&1; then + echo "Tag ${RELEASE_TAG} already exists locally. Skipping tag creation." + else + git tag -a "${RELEASE_TAG}" -m "${RELEASE_TAG}" + fi + + - name: Push release commit and tag + run: | + if [[ "${SHOULD_BUMP}" = "true" ]]; then + git push origin HEAD:main + fi + + if git ls-remote --tags origin "refs/tags/${RELEASE_TAG}" | grep -q .; then + echo "Tag ${RELEASE_TAG} already exists on origin. Skipping tag push." + else + git push origin "${RELEASE_TAG}" + fi + + - name: Detect existing npm publication + run: | + if npm view "@roastcodes/ttdash@${RELEASE_VERSION}" version >/dev/null 2>&1; then + echo "NPM_VERSION_EXISTS=true" >> "$GITHUB_ENV" + echo "Package version already exists on npm. Skipping publish." + else + echo "NPM_VERSION_EXISTS=false" >> "$GITHUB_ENV" + fi + - name: Publish package to npm + if: env.NPM_VERSION_EXISTS != 'true' run: npm publish - name: Wait for npm registry propagation run: | - VERSION="$(node -p "require('./package.json').version")" for attempt in 1 2 3 4 5 6; do - if npm view "@roastcodes/ttdash@${VERSION}" version >/dev/null 2>&1; then + if npm view "@roastcodes/ttdash@${RELEASE_VERSION}" version >/dev/null 2>&1; then exit 0 fi sleep 10 @@ -81,23 +187,22 @@ jobs: - name: Verify registry install paths run: | - VERSION="$(node -p "require('./package.json').version")" node scripts/verify-registry-install.js \ --package @roastcodes/ttdash \ - --version "${VERSION}" \ + --version "${RELEASE_VERSION}" \ --retries 6 \ --retry-delay-ms 10000 - name: Create GitHub release env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then - echo "Release ${GITHUB_REF_NAME} already exists. Skipping creation." + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then + echo "Release ${RELEASE_TAG} already exists. Skipping creation." exit 0 fi - gh release create "${GITHUB_REF_NAME}" \ + gh release create "${RELEASE_TAG}" \ --verify-tag \ - --title "${GITHUB_REF_NAME}" \ + --title "${RELEASE_TAG}" \ --generate-notes diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb22c1..11287b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +## [6.1.4] - 2026-04-11 + +### Added +- **GitHub-driven release flow** — releases can now be started manually from GitHub Actions with a target version input, instead of relying on a locally created tag on `main` +- **CI release gate** — the release workflow now verifies that the latest `CI` run for the current `main` commit completed successfully before any version bump, tag, or npm publish step begins +- **Release app verification** — a dedicated GitHub API helper now validates the `CI` precondition directly from the workflow, so release gating stays tied to the exact `main` SHA + +### Improved +- **Single human-managed version source** — the frontend app version is now injected from `package.json` at build time instead of being maintained as a second manual version constant +- **Protected-branch compatibility** — the release workflow now uses the dedicated `ttdash-release` GitHub App token for checkout, push, tag creation, and GitHub release creation, so the release path works cleanly with branch rules and ruleset bypasses +- **Release recovery behavior** — rerunning a failed release with the same version now resumes cleanly when the version bump commit, tag, or npm publication already exists +- **Release documentation** — the maintainer guide now documents the GitHub App setup, ruleset expectations, workflow-dispatch release path, and the new post-publish verification model + ## [6.1.0] - 2026-04-11 ### Added diff --git a/RELEASING.md b/RELEASING.md index eea57e8..dcea89c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -9,61 +9,62 @@ Before the first public release, configure npm Trusted Publishing for this repos 3. Ensure you have publish rights in the `roastcodes` npm organization 4. In npm package settings, add this GitHub repository as a trusted publisher for `@roastcodes/ttdash` 5. Confirm the GitHub Actions release workflow is allowed to request an OIDC token +6. Install the `ttdash-release` GitHub App on `roastcodes/ttdash` +7. Add `APP_ID` and `APP_PRIVATE_KEY` as Actions secrets for this repository +8. Add the `ttdash-release` GitHub App as a bypass actor in the `main` ruleset Trusted Publishing is preferred because it avoids long-lived npm tokens and enables provenance for public publishes. If you want npm provenance on the published package, the GitHub repository must be public when the release workflow runs. -## Release Checklist - -1. Update `package.json` version -2. Add the matching section to `CHANGELOG.md` -3. Run the full local verification suite: +## GitHub Repository Setup -```bash -npm run test:unit:coverage -npm run build -npm run verify:package -PLAYWRIGHT_TEST_PORT=3016 npm_config_cache=/tmp/ttdash-npm-cache npm run test:e2e -``` +Before using the manual release workflow, make sure: -4. Merge the release changes to `main` -5. Create and push a tag that matches the package version exactly +1. `main` is protected and requires the `CI` status check before merges +2. CodeQL is enabled in the GitHub UI if you want it as a manual release gate +3. the `ttdash-release` GitHub App is allowed to push the version-bump commit and annotated tag back to `main` -Example: +If branch protection or rulesets block the `ttdash-release` app from writing to `main` or pushing `v*` tags, the workflow will fail when it tries to push the release commit or tag. -```bash -bash scripts/tag-main-release.sh -``` +## Release Checklist -Optional explicit version: +1. Merge the intended release state to `main` +2. Confirm the latest `CI` run on `main` succeeded +3. Confirm CodeQL is green in the GitHub UI +4. Start the `Release` workflow manually from GitHub Actions and provide the target version in `x.y.z` format -```bash -bash scripts/tag-main-release.sh 6.1.3 -``` - -Dry-run preview: +Optional local confidence check before starting the workflow: ```bash -bash scripts/tag-main-release.sh --dry-run +npm run test:unit:coverage +npm run build +npm run verify:package +PLAYWRIGHT_TEST_PORT=3016 npm_config_cache=/tmp/ttdash-npm-cache npm run test:e2e ``` ## What the Release Workflow Does -On a `v*` tag push, the workflow: - -1. verifies the tagged commit is on `main` -2. verifies the tag matches `package.json` -3. runs unit/integration tests with coverage -4. builds the production bundle -5. verifies the packed npm artifact -6. runs the Playwright smoke suite -7. publishes `@roastcodes/ttdash` to npm through Trusted Publishing -8. waits for npm registry propagation -9. verifies: +On a manual `workflow_dispatch` run against `main`, the workflow: + +1. verifies the requested version is greater than the current `package.json` version + or resumes a partially completed release when the requested version is already on `main` +2. verifies the latest `CI` run for the current `main` commit succeeded +3. bumps `package.json` and `package-lock.json` to the requested version +4. runs unit/integration tests with coverage +5. builds the production bundle +6. verifies the packed npm artifact +7. runs the Playwright smoke suite +8. creates and pushes the release commit and annotated tag +9. publishes `@roastcodes/ttdash` to npm through Trusted Publishing +10. waits for npm registry propagation +11. verifies: - `npx --yes @roastcodes/ttdash@ --help` - `bunx @roastcodes/ttdash@ --help` -10. creates the GitHub release +12. creates the GitHub release + +Note: the workflow reruns the release-critical test suite itself after the version bump. This is necessary because the workflow-created push back to `main` should not be relied on to trigger the normal `CI` workflow again. +If a release fails after the version bump was already pushed, rerunning the workflow with the same version resumes that release instead of forcing another version bump. ## Post-Publish Checks diff --git a/package.json b/package.json index 63cb634..5d7185d 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "test:e2e:ci": "playwright test", "test:all": "npm run test:unit && npm run test:e2e", "pack:dry-run": "npm pack --dry-run", - "tag:main-release": "bash scripts/tag-main-release.sh", "verify:package": "node scripts/verify-package.js", "verify:registry-install": "node scripts/verify-registry-install.js", "verify:release": "npm run test:unit:coverage && npm run build && npm run verify:package", diff --git a/scripts/tag-main-release.sh b/scripts/tag-main-release.sh deleted file mode 100644 index 5727530..0000000 --- a/scripts/tag-main-release.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -usage() { - cat <<'EOF' -Usage: - bash scripts/tag-main-release.sh [version] [--dry-run] - -Behavior: - - must be run from a clean local main branch - - fetches origin - - fast-forwards local main to origin/main - - creates annotated tag v - - pushes the tag to origin - -Defaults: - - if version is omitted, package.json version is used - -Examples: - bash scripts/tag-main-release.sh - bash scripts/tag-main-release.sh 6.1.3 - bash scripts/tag-main-release.sh --dry-run -EOF -} - -VERSION="" -DRY_RUN=0 - -for arg in "$@"; do - case "$arg" in - -h|--help) - usage - exit 0 - ;; - --dry-run) - DRY_RUN=1 - ;; - *) - if [[ -n "$VERSION" ]]; then - echo "Only one version argument is allowed." - usage - exit 1 - fi - VERSION="$arg" - ;; - esac -done - -CURRENT_BRANCH="$(git branch --show-current)" -if [[ "$CURRENT_BRANCH" != "main" ]]; then - echo "This script must be run from the local main branch. Current branch: $CURRENT_BRANCH" - exit 1 -fi - -if [[ -n "$(git status --porcelain)" ]]; then - echo "Working tree is not clean. Commit or stash changes before tagging a release." - exit 1 -fi - -run() { - echo "+ $*" - if [[ "$DRY_RUN" -eq 0 ]]; then - "$@" - fi -} - -run git fetch origin -run git pull --ff-only origin main - -PACKAGE_VERSION="$(node -p "require('./package.json').version")" - -if [[ -z "$VERSION" ]]; then - VERSION="$PACKAGE_VERSION" -fi - -if [[ "$VERSION" != "$PACKAGE_VERSION" ]]; then - echo "Version mismatch after sync: package.json is $PACKAGE_VERSION but requested version is $VERSION." - exit 1 -fi - -TAG_NAME="v$VERSION" - -if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then - echo "Tag already exists locally: $TAG_NAME" - exit 1 -fi - -if git ls-remote --tags origin "refs/tags/$TAG_NAME" | grep -q .; then - echo "Tag already exists on origin: $TAG_NAME" - exit 1 -fi - -run git tag -a "$TAG_NAME" -m "$TAG_NAME" -run git push origin "$TAG_NAME" - -if [[ "$DRY_RUN" -eq 1 ]]; then - echo "Dry run complete." -else - echo "Release tag pushed: $TAG_NAME" -fi diff --git a/scripts/verify-main-ci.js b/scripts/verify-main-ci.js new file mode 100644 index 0000000..afa3225 --- /dev/null +++ b/scripts/verify-main-ci.js @@ -0,0 +1,149 @@ +#!/usr/bin/env node + +const DEFAULT_RETRIES = 30; +const DEFAULT_RETRY_DELAY_MS = 10000; + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function log(message) { + process.stdout.write(`${message}\n`); +} + +function parseArgs(argv) { + const options = { + repo: null, + workflow: null, + branch: 'main', + sha: null, + retries: DEFAULT_RETRIES, + retryDelayMs: DEFAULT_RETRY_DELAY_MS, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + + if (arg === '--repo' && next) { + options.repo = next; + index += 1; + continue; + } + + if (arg === '--workflow' && next) { + options.workflow = next; + index += 1; + continue; + } + + if (arg === '--branch' && next) { + options.branch = next; + index += 1; + continue; + } + + if (arg === '--sha' && next) { + options.sha = next; + index += 1; + continue; + } + + if (arg === '--retries' && next) { + options.retries = Number.parseInt(next, 10); + index += 1; + continue; + } + + if (arg === '--retry-delay-ms' && next) { + options.retryDelayMs = Number.parseInt(next, 10); + index += 1; + continue; + } + } + + if (!options.repo || !options.workflow || !options.sha) { + fail('Usage: node scripts/verify-main-ci.js --repo --workflow --sha [--branch main] [--retries N] [--retry-delay-ms MS]'); + } + + if (!Number.isInteger(options.retries) || options.retries <= 0) { + fail(`Invalid retries value: ${options.retries}`); + } + + if (!Number.isInteger(options.retryDelayMs) || options.retryDelayMs < 0) { + fail(`Invalid retry delay value: ${options.retryDelayMs}`); + } + + return options; +} + +function getToken() { + return process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null; +} + +async function sleep(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchWorkflowRuns(options, token) { + const url = new URL(`https://api.github.com/repos/${options.repo}/actions/workflows/${encodeURIComponent(options.workflow)}/runs`); + url.searchParams.set('branch', options.branch); + url.searchParams.set('event', 'push'); + url.searchParams.set('per_page', '30'); + + const response = await fetch(url, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'ttdash-release-workflow', + }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`GitHub API request failed with ${response.status}: ${body}`); + } + + return response.json(); +} + +function describeRun(run) { + return `${run.name} (${run.status}/${run.conclusion ?? 'pending'})`; +} + +async function main() { + const token = getToken(); + if (!token) { + fail('GITHUB_TOKEN or GH_TOKEN is required.'); + } + + const options = parseArgs(process.argv.slice(2)); + + for (let attempt = 1; attempt <= options.retries; attempt += 1) { + const payload = await fetchWorkflowRuns(options, token); + const run = payload.workflow_runs.find((candidate) => candidate.head_sha === options.sha); + + if (!run) { + log(`CI workflow run for ${options.sha} not found yet (attempt ${attempt}/${options.retries}).`); + } else if (run.status !== 'completed') { + log(`CI workflow run is still in progress: ${describeRun(run)} (attempt ${attempt}/${options.retries}).`); + } else if (run.conclusion === 'success') { + log(`Verified CI success for ${options.sha}: ${describeRun(run)}.`); + return; + } else { + fail(`CI workflow for ${options.sha} completed with ${run.conclusion}. Release aborted.`); + } + + if (attempt < options.retries) { + await sleep(options.retryDelayMs); + } + } + + fail(`CI workflow for ${options.sha} did not reach a successful conclusion in time.`); +} + +main().catch((error) => { + fail(error instanceof Error ? error.message : String(error)); +}); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9b49db1..1a0a9c0 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,4 +1,4 @@ -export const VERSION = '6.1.3' +export const VERSION = __APP_VERSION__ export const MODEL_COLORS: Record = { 'Opus 4.6': 'hsl(262, 60%, 55%)', diff --git a/vite-env.d.ts b/vite-env.d.ts index 11f02fe..54eaa07 100644 --- a/vite-env.d.ts +++ b/vite-env.d.ts @@ -1 +1,3 @@ /// + +declare const __APP_VERSION__: string diff --git a/vite.config.ts b/vite.config.ts index 29b0ab1..7ecff3f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,9 +2,15 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import path from 'path' +import fs from 'fs' + +const packageJson = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf8')) export default defineConfig({ plugins: [react(), tailwindcss()], + define: { + __APP_VERSION__: JSON.stringify(packageJson.version), + }, resolve: { alias: { '@': path.resolve(__dirname, './src'),