diff --git a/.github/workflows/opencode-smoke.yml b/.github/workflows/opencode-smoke.yml index e6e3809..b729e07 100644 --- a/.github/workflows/opencode-smoke.yml +++ b/.github/workflows/opencode-smoke.yml @@ -13,7 +13,11 @@ on: default: '30' workflow_call: inputs: - tag: + artifact: + type: string + required: false + default: '' + version: type: string required: true timeout: @@ -29,18 +33,58 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] permissions: + actions: read contents: read steps: - - uses: actions/checkout@v4 + - name: Download packed artifact + if: inputs.artifact != '' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: ${{ inputs.artifact }} + path: ${{ runner.temp }}/package - name: Resolve plugin version id: version + env: + ARTIFACT_NAME: ${{ inputs.artifact }} + REQUESTED_VERSION: ${{ github.event_name == 'workflow_call' && inputs.version || '' }} + REQUESTED_TAG: ${{ inputs.tag }} run: | - VERSION=$(npm view opencode-synced@${{ inputs.tag }} version) + if [ -n "$ARTIFACT_NAME" ]; then + TARBALL=$(find "${RUNNER_TEMP}/package" -maxdepth 1 -name '*.tgz' -print -quit) + if [ -z "$TARBALL" ]; then + echo "Missing package artifact." + exit 1 + fi + VERSION=$(tar -xOf "$TARBALL" package/package.json | jq -r '.version') + if [ "$VERSION" != "$REQUESTED_VERSION" ]; then + echo "Expected package version $REQUESTED_VERSION, found $VERSION." + exit 1 + fi + echo "spec=opencode-synced@file:$TARBALL" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + exit 0 + fi + + PACKAGE_SPEC="opencode-synced@${REQUESTED_VERSION:-$REQUESTED_TAG}" + VERSION="" + for attempt in 1 2 3 4 5 6; do + VERSION=$(npm view "$PACKAGE_SPEC" version 2>/dev/null || true) + if [ -n "$VERSION" ]; then + break + fi + echo "Waiting for npm registry to expose $PACKAGE_SPEC (attempt $attempt/6)" + sleep 10 + done if [ -z "$VERSION" ]; then - echo "No version found for tag: ${{ inputs.tag }}" + echo "No version found for: $PACKAGE_SPEC" + exit 1 + fi + if [ -n "$REQUESTED_VERSION" ] && [ "$VERSION" != "$REQUESTED_VERSION" ]; then + echo "Expected exact version $REQUESTED_VERSION, resolved $VERSION" exit 1 fi + echo "spec=opencode-synced@$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Install opencode @@ -53,7 +97,7 @@ jobs: - name: Configure clean opencode home env: opencode_home: ${{ runner.temp }}/opencode-home - PLUGIN_VERSION: ${{ steps.version.outputs.version }} + PLUGIN_SPEC: ${{ steps.version.outputs.spec }} run: | export HOME="$opencode_home" export XDG_CONFIG_HOME="$HOME/.config" @@ -65,7 +109,7 @@ jobs: cat > "$XDG_CONFIG_HOME/opencode/opencode.json" <> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + package: + needs: validate runs-on: ubuntu-latest + permissions: + contents: read outputs: - requested_tag: ${{ steps.publish.outputs.requested_tag }} + artifact: opencode-synced-package version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ needs.validate.outputs.ref }} + persist-credentials: false - uses: jdx/mise-action@d6e32c1796099e0f1f3ac741c220a8b7eae9e5dd with: @@ -35,67 +138,110 @@ jobs: - name: Setup run: mise run setup - - name: Build - run: mise run build - - - id: inputs - uses: simenandre/setup-inputs@v1 - - - name: Publish candidate to npm with OIDC - id: publish + - name: Prepare package version + id: version + env: + REQUESTED_TAG: ${{ needs.validate.outputs.tag }} run: | - TAG="${{ steps.inputs.outputs.tag }}" - if [ -z "$TAG" ]; then - TAG="latest" + BASE_VERSION=$(node --print "require('./package.json').version") + if [ -z "$BASE_VERSION" ]; then + echo "Missing package version." + exit 1 + fi + if [[ ! "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid semantic package version: $BASE_VERSION" + exit 1 fi - echo "requested_tag=$TAG" >> "$GITHUB_OUTPUT" + VERSION="$BASE_VERSION" + if [ "$REQUESTED_TAG" = "next" ]; then + BASE_VERSION_WITHOUT_PRERELEASE="${BASE_VERSION%%[-+]*}" + if [[ ! "$BASE_VERSION_WITHOUT_PRERELEASE" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid package version for prerelease: $BASE_VERSION" + exit 1 + fi + + VERSION="${BASE_VERSION_WITHOUT_PRERELEASE}-next.${GITHUB_RUN_ID}.${GITHUB_RUN_ATTEMPT}" + npm version "$VERSION" \ + --no-git-tag-version \ + --ignore-scripts \ + --allow-same-version + fi - CANDIDATE_TAG="next" - echo "Publishing candidate with tag: $CANDIDATE_TAG (requested: $TAG)" - mise run publish --tag "$CANDIDATE_TAG" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Resolve published version - id: version + - name: Build + run: mise run build + + - name: Pack exact artifact run: | - VERSION=$(npm view opencode-synced@next version) - if [ -z "$VERSION" ]; then - echo "Failed to resolve version from npm tag: next" - exit 1 - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + mkdir -p "${RUNNER_TEMP}/package" + npm pack --pack-destination "${RUNNER_TEMP}/package" - smoke: - needs: publish + - name: Upload exact artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: opencode-synced-package + path: ${{ runner.temp }}/package/*.tgz + if-no-files-found: error + retention-days: 1 + + prepublish-smoke: + needs: package + permissions: + actions: read + contents: read uses: ./.github/workflows/opencode-smoke.yml with: - tag: next + artifact: ${{ needs.package.outputs.artifact }} + version: ${{ needs.package.outputs.version }} timeout: 20 - continue-on-error: true - promote_latest: - needs: [publish, smoke] - if: needs.publish.outputs.requested_tag == 'latest' + publish: + needs: [validate, package, prepublish-smoke] runs-on: ubuntu-latest permissions: + actions: read id-token: write - contents: read + outputs: + version: ${{ needs.package.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 24 - - uses: jdx/mise-action@d6e32c1796099e0f1f3ac741c220a8b7eae9e5dd + - name: Install trusted-publishing npm version + run: npm install --global --ignore-scripts npm@11.6.3 + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: - install: true - cache: true - experimental: true + name: ${{ needs.package.outputs.artifact }} + path: ${{ runner.temp }}/package - - name: Promote latest dist-tag + - name: Publish exact artifact to npm with OIDC + env: + EXPECTED_VERSION: ${{ needs.package.outputs.version }} + REQUESTED_TAG: ${{ needs.validate.outputs.tag }} run: | - VERSION="${{ needs.publish.outputs.version }}" - if [ -z "$VERSION" ]; then - echo "Missing published version." + TARBALL=$(find "${RUNNER_TEMP}/package" -maxdepth 1 -name '*.tgz' -print -quit) + if [ -z "$TARBALL" ]; then + echo "Missing package artifact." exit 1 fi + ACTUAL_VERSION=$(tar -xOf "$TARBALL" package/package.json | jq -r '.version') + if [ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]; then + echo "Expected package version $EXPECTED_VERSION, found $ACTUAL_VERSION." + exit 1 + fi + npm publish "$TARBALL" --access public --tag "$REQUESTED_TAG" --ignore-scripts - echo "Promoting opencode-synced@$VERSION to latest" - npm dist-tag add "opencode-synced@$VERSION" latest + postpublish-smoke: + # Trusted publishing authorizes npm publish only. A failed post-publish smoke cannot roll back + # an immutable npm version, so the exact packed artifact must pass prepublish-smoke first. + needs: publish + permissions: + contents: read + uses: ./.github/workflows/opencode-smoke.yml + with: + version: ${{ needs.publish.outputs.version }} + timeout: 20 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f9b421..07e0182 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,53 +8,69 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} -permissions: - contents: write - pull-requests: write +permissions: {} jobs: process: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write outputs: releases_created: ${{ steps.release-please.outputs.releases_created }} prs_created: ${{ steps.release-please.outputs.prs_created }} + release_sha: ${{ steps.release-please.outputs.sha }} + release_pr: ${{ steps.release-please.outputs.pr }} steps: - - uses: google-github-actions/release-please-action@v4 + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 id: release-please with: token: ${{ secrets.GITHUB_TOKEN }} - release-type: node skip-github-pull-request: false - name: Checkout - if: ${{ steps.release-please.outputs.releases_created }} - uses: actions/checkout@v4 + if: steps.release-please.outputs.releases_created == 'true' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ steps.release-please.outputs.sha }} + fetch-depth: 0 - name: Update latest tag - if: ${{ steps.release-please.outputs.releases_created }} + if: steps.release-please.outputs.releases_created == 'true' run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com git tag -f latest - git push origin latest + git push --force origin refs/tags/latest dispatch-publish: needs: process runs-on: ubuntu-latest + permissions: + contents: write if: needs.process.outputs.releases_created == 'true' || needs.process.outputs.prs_created == 'true' steps: - name: Dispatch publish for releases if: needs.process.outputs.releases_created == 'true' - uses: peter-evans/repository-dispatch@v2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - event-type: publish-package - client-payload: '{"tag": "latest"}' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_SHA: ${{ needs.process.outputs.release_sha }} + run: | + gh api --method POST "repos/${GITHUB_REPOSITORY}/dispatches" \ + --field event_type=publish-package \ + --field client_payload[tag]=latest \ + --field client_payload[ref]="$RELEASE_SHA" - name: Dispatch publish for prerelease if: needs.process.outputs.prs_created == 'true' - uses: peter-evans/repository-dispatch@v2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - event-type: publish-package - client-payload: '{"tag": "next"}' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_PR_NUMBER: ${{ fromJSON(needs.process.outputs.release_pr).number }} + run: | + RELEASE_PR_SHA=$(gh api \ + "repos/${GITHUB_REPOSITORY}/pulls/${RELEASE_PR_NUMBER}" \ + --jq '.head.sha') + gh api --method POST "repos/${GITHUB_REPOSITORY}/dispatches" \ + --field event_type=publish-package \ + --field client_payload[tag]=next \ + --field client_payload[ref]="$RELEASE_PR_SHA" diff --git a/.mise/tasks/setup b/.mise/tasks/setup index fbb6dc9..196ce4a 100755 --- a/.mise/tasks/setup +++ b/.mise/tasks/setup @@ -5,7 +5,7 @@ echo "" echo "🍜 Setting up project" echo "" -bun install +bun install --frozen-lockfile echo "" echo "👍 Done" diff --git a/README.md b/README.md index 191baf9..03b0d1b 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,10 @@ Create `~/.config/opencode/opencode-synced.jsonc`: - `~/.local/state/opencode/model.json` (model favorites) - Any additional paths in `extraConfigPaths` (allowlist, files or folders). You do not need to include default paths like `~/.config/opencode/skills` or `~/.agents`. +`~/.agents/` is enabled by default and may contain instructions or skills you consider private. +Review it before syncing, keep the sync repository private when needed, or set +`"includeAgentsDir": false` to opt out. + Disable default directory sync by setting: - `"includeOpencodeSkills": false` to skip `~/.config/opencode/skills/` - `"includeAgentsDir": false` to skip `~/.agents/` diff --git a/release-please-config.json b/release-please-config.json index 5a4050c..5875d64 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -5,8 +5,6 @@ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "include-v-in-tag": true, "include-component-in-tag": false, - "versioning": "prerelease", - "prerelease": true, "bump-minor-pre-major": true, "release-type": "node" } diff --git a/src/release-workflows.test.ts b/src/release-workflows.test.ts new file mode 100644 index 0000000..5e63b24 --- /dev/null +++ b/src/release-workflows.test.ts @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const readProjectFile = (path: string): string => + readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); + +const publishWorkflow = readProjectFile('.github/workflows/publish.yml'); +const releaseWorkflow = readProjectFile('.github/workflows/release.yml'); +const smokeWorkflow = readProjectFile('.github/workflows/opencode-smoke.yml'); + +describe('release workflows', () => { + it('uses one stable release-please configuration', () => { + const config = JSON.parse(readProjectFile('release-please-config.json')) as Record< + string, + unknown + >; + + expect(config['release-type']).toBe('node'); + expect(config.prerelease).toBeUndefined(); + expect(config.versioning).toBeUndefined(); + expect(releaseWorkflow).toContain('googleapis/release-please-action@'); + expect(releaseWorkflow).not.toContain('google-github-actions/release-please-action@'); + expect(releaseWorkflow).not.toContain('release-type: node'); + }); + + it('pins external actions to full commit SHAs', () => { + const workflows = [publishWorkflow, releaseWorkflow, smokeWorkflow]; + const externalUses = workflows.flatMap((workflow) => + [...workflow.matchAll(/uses:\s+([^\s]+)/g)] + .map((match) => match[1]) + .filter((value): value is string => Boolean(value) && !value.startsWith('./')) + ); + + expect(externalUses.length).toBeGreaterThan(0); + for (const action of externalUses) { + expect(action).toMatch(/@[0-9a-f]{40}$/); + } + }); + + it('fails closed around canonical publish refs and OIDC', () => { + expect(publishWorkflow).toContain("description: 'Exact full commit SHA to publish'"); + expect(publishWorkflow).toContain('Publishing requires a full commit SHA.'); + expect(publishWorkflow).toContain('Latest publishing requires the commit behind'); + expect(publishWorkflow).toContain( + 'Next publishing requires the exact SHA of the open release-please PR.' + ); + expect(publishWorkflow).toContain('persist-credentials: false'); + expect(publishWorkflow).toContain('id-token: write'); + expect(publishWorkflow).not.toContain('simenandre/setup-inputs'); + expect(publishWorkflow).not.toContain('npm dist-tag'); + expect(publishWorkflow).not.toContain('continue-on-error'); + }); + + it('smokes the exact artifact before and after direct publication', () => { + expect(publishWorkflow).toContain('prepublish-smoke:'); + expect(publishWorkflow).toContain('postpublish-smoke:'); + expect(publishWorkflow).toContain('npm publish "$TARBALL"'); + expect(smokeWorkflow).toContain('spec=opencode-synced@file:$TARBALL'); + expect(smokeWorkflow).toContain('Expected exact version $REQUESTED_VERSION'); + }); + + it('uses string comparisons for release-please boolean outputs and frozen setup', () => { + expect(releaseWorkflow).toContain("outputs.releases_created == 'true'"); + expect(releaseWorkflow).toContain("outputs.prs_created == 'true'"); + expect(readProjectFile('.mise/tasks/setup')).toContain('bun install --frozen-lockfile'); + }); +});