diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 00000000..0c8dc445 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,157 @@ +--- +# yamllint disable rule:line-length +name: publish + +on: + pull_request: + paths: + - '.github/workflows/publish.yaml' + - 'images/homelab-workspace/**' + - 'templates/kubernetes/homelab-workspace/**' + release: + types: + - published + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.release.tag_name || github.ref }} + cancel-in-progress: true + +env: + image_name: ${{ secrets.CONTAINER_REGISTRY }}/${{ vars.CONTAINER_REGISTRY_PATH }}/coder-workspace + image_cache_name: ${{ secrets.CONTAINER_REGISTRY }}/${{ vars.CONTAINER_REGISTRY_CACHE_PATH }}/coder-workspace + image_path: images/homelab-workspace + template_path: templates/kubernetes/homelab-workspace + publish_mode: ${{ github.event_name == 'release' && 'release' || 'test' }} + source_ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.head_ref || github.ref_name }} + source_sha: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.pull_request.head.sha || github.sha }} + +jobs: + publish-image: + permissions: + contents: read + packages: write + uses: ppat/github-workflows/.github/workflows/build-docker-image.yaml@5a96ced8ceefd58062f6b91ee9d6f3a31cd06e1c # v6.0.0 (unmerged PR #615 -- repoint to the v6.0.0 tag before merge) + with: + image_context_path: images/homelab-workspace + label_title: "Homelab Workspace" + label_description: "Homelab workspace image" + platforms: linux/amd64,linux/arm64 + private_registry_repository: ${{ vars.CONTAINER_REGISTRY_PATH }}/coder-workspace + private_registry_build_cache: ${{ vars.CONTAINER_REGISTRY_CACHE_PATH }}/coder-workspace + git_ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.head_ref || github.ref_name }} + ghcr_repository: ${{ github.event_name == 'release' && 'ppat/coder-workspace' || '' }} + timeout_minutes: 180 + secrets: + private_registry_username: ${{ secrets.CONTAINER_REGISTRY_USERNAME }} + private_registry_token: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }} + private_registry: ${{ secrets.CONTAINER_REGISTRY }} + tailscale_oauth_client_id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }} + tailscale_oauth_secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} + build_secrets: | + FETCH_GH_TOKEN=${{ secrets.GITHUB_TOKEN }} + + publish-template: + needs: [publish-image] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + ref: ${{ env.source_ref }} + + - name: Tailscale Connect + uses: tailscale/github-action@6cae46e2d796f265265cfcf628b72a32b4d7cade # v3 + with: + oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} + tags: tag:github-action-ci-runner + # renovate: datasource=github-releases depName=tailscale/tailscale + version: "1.102.3" + + - name: Login to Coder + id: login + env: + CODER_URL: "${{ secrets.CODER_URL }}" + shell: bash + # yamllint disable-line rule:indentation + run: | + while ! curl -fsSL "${CODER_URL}" > /dev/null; do + echo "Waiting for coder service to be ready... sleep 5s!" + sleep 5 + done + echo "Coder service is accepting connections..." + echo + + echo "Generating authentication token..." + export CODER_SESSION_TOKEN + CODER_SESSION_TOKEN=$(curl -X POST "${CODER_URL}/api/v2/users/login" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + --data '{"email": "'${{ secrets.CODER_EMAIL }}'", "password": "'${{ secrets.CODER_PASSWORD }}'"}' \ + | jq -r .session_token) + echo "::add-mask::${CODER_SESSION_TOKEN}" + if [[ -z "${CODER_SESSION_TOKEN}" || "${CODER_SESSION_TOKEN}" = "null" ]]; then + echo "Error generating coder authentication token." + exit 1 + fi + echo "Authentication token generated." + echo + echo "Determining coder version..." + export CODER_VERSION + CODER_VERSION=$(curl -fsSL "${CODER_URL}/api/v2/buildinfo" | jq -r .version | cut -d'+' -f1 | cut -d'v' -f2) + echo "Installing coder CLI..." + curl -fsSL https://coder.com/install.sh | sh -s -- --method standalone --version "${CODER_VERSION}" + echo + echo "Logging into Coder..." + coder login --use-token-as-session "${CODER_URL}" + + - name: Publish template + id: publish-template + env: + TEMPLATE_DIR: ${{ env.template_path }} + TEMPLATE_VERSION: ${{ env.source_sha }} + WORKSPACE_IMAGE: "${{ secrets.CONTAINER_REGISTRY }}/${{ vars.CONTAINER_REGISTRY_PATH }}/coder-workspace:${{ needs.publish-image.outputs.image_tag }}" + PUBLISH_MODE: ${{ env.publish_mode }} + shell: bash + # yamllint disable-line rule:indentation + run: | + if [[ "${PUBLISH_MODE}" == "release" ]]; then + export TEMPLATE_NAME + TEMPLATE_NAME="$(echo "${TEMPLATE_DIR}" | cut -d/ -f3)" + export RELEASE_MSG="[Release Notes](https://github.com/${{ github.repository }}/releases/tag/${TEMPLATE_VERSION})" + export TEST_MODE=false + else + export TEMPLATE_NAME + TEMPLATE_NAME="$(echo "${TEMPLATE_DIR}" | cut -d/ -f3)-test" + export RELEASE_MSG="[Changes](https://github.com/${{ github.repository }}/commit/${TEMPLATE_VERSION})" + export TEST_MODE=true + fi + echo "Publishing template ${TEMPLATE_DIR} as ${TEMPLATE_NAME}..." + set -x + coder template push \ + --directory "${TEMPLATE_DIR}" \ + --var "workspace_image=${WORKSPACE_IMAGE}" \ + --var test_mode=${TEST_MODE} \ + --name "${TEMPLATE_VERSION}" \ + --message "${RELEASE_MSG}" \ + --yes \ + "${TEMPLATE_NAME}" + set +x + echo + echo "Confirming template has been published..." + coder templates list --output json > /tmp/templates.json + export SELECTED_TEMPLATE + SELECTED_TEMPLATE=$(cat /tmp/templates.json | jq -r '.[] | select(.Template.name == "'"${TEMPLATE_NAME}"'")') + if [[ -z $SELECTED_TEMPLATE ]]; then + echo "Could not find any template published as $TEMPLATE_NAME." + exit 1 + fi + echo "Template has been published successfully." + + - name: Tailscale Disconnect + if: success() || failure() + run: sudo -E tailscale logout diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5035e96f..b3c9d815 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -6,161 +6,22 @@ on: pull_request: paths: - '.github/workflows/release.yaml' - - '.releaserc.js' - - 'images/homelab-workspace/**' - - 'templates/**' + - 'release-please-config.json' + push: + branches: + - main workflow_dispatch: - inputs: - test_publish: - description: "Test Publish" - required: true - type: boolean concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} -env: - image_name: ${{ secrets.CONTAINER_REGISTRY }}/${{ vars.CONTAINER_REGISTRY_PATH }}/coder-workspace - image_cache_name: ${{ secrets.CONTAINER_REGISTRY }}/${{ vars.CONTAINER_REGISTRY_CACHE_PATH }}/coder-workspace - image_path: images/homelab-workspace - template_path: templates/kubernetes/homelab-workspace - default_branch: main +permissions: {} jobs: - create-release: - uses: ppat/github-workflows/.github/workflows/release-semantic.yaml@5a96ced8ceefd58062f6b91ee9d6f3a31cd06e1c # v6.0.0 + release: + uses: ppat/github-workflows/.github/workflows/release-please.yaml@5a96ced8ceefd58062f6b91ee9d6f3a31cd06e1c # v6.0.0 with: - dry_run: ${{ (github.event_name == 'pull_request') || (github.event_name == 'workflow_dispatch' && github.event.inputs.test_publish == 'true') }} - release_branch: ${{ github.head_ref || github.ref_name }} + dry_run: ${{ github.event_name == 'pull_request' }} secrets: app_id: ${{ secrets.HOMELAB_BOT_APP_ID }} app_private_key: ${{ secrets.HOMELAB_BOT_APP_PRIVATE_KEY }} - - publish-image: - needs: [create-release] - permissions: - contents: read - packages: write - uses: ppat/github-workflows/.github/workflows/build-docker-image.yaml@5a96ced8ceefd58062f6b91ee9d6f3a31cd06e1c # v6.0.0 (unmerged PR #615 -- repoint to the v6.0.0 tag before merge) - with: - image_context_path: images/homelab-workspace - label_title: "Homelab Workspace" - label_description: "Homelab workspace image" - platforms: linux/amd64,linux/arm64 - private_registry_repository: ${{ vars.CONTAINER_REGISTRY_PATH }}/coder-workspace - private_registry_build_cache: ${{ vars.CONTAINER_REGISTRY_CACHE_PATH }}/coder-workspace - git_ref: ${{ needs.create-release.outputs.released_gitref }} - ghcr_repository: ${{ needs.create-release.outputs.released_version != 'v0.0.0' && 'ppat/coder-workspace' || '' }} - timeout_minutes: 180 - secrets: - private_registry_username: ${{ secrets.CONTAINER_REGISTRY_USERNAME }} - private_registry_token: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }} - private_registry: ${{ secrets.CONTAINER_REGISTRY }} - tailscale_oauth_client_id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }} - tailscale_oauth_secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} - build_secrets: | - FETCH_GH_TOKEN=${{ secrets.GITHUB_TOKEN }} - - publish-template: - needs: [create-release, publish-image] - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 1 - persist-credentials: false - - - name: Tailscale Connect - uses: tailscale/github-action@6cae46e2d796f265265cfcf628b72a32b4d7cade # v3 - with: - oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }} - oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} - tags: tag:github-action-ci-runner - # renovate: datasource=github-releases depName=tailscale/tailscale - version: "1.102.3" - - - name: Login to Coder - id: login - env: - CODER_URL: "${{ secrets.CODER_URL }}" - shell: bash - # yamllint disable-line rule:indentation - run: | - while ! curl -fsSL "${CODER_URL}" > /dev/null; do - echo "Waiting for coder service to be ready... sleep 5s!" - sleep 5 - done - echo "Coder service is accepting connections..." - echo - - echo "Generating authentication token..." - export CODER_SESSION_TOKEN - CODER_SESSION_TOKEN=$(curl -X POST "${CODER_URL}/api/v2/users/login" \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json' \ - --data '{"email": "'${{ secrets.CODER_EMAIL }}'", "password": "'${{ secrets.CODER_PASSWORD }}'"}' \ - | jq -r .session_token) - echo "::add-mask::${CODER_SESSION_TOKEN}" - if [[ -z "${CODER_SESSION_TOKEN}" || "${CODER_SESSION_TOKEN}" = "null" ]]; then - echo "Error generating coder authentication token." - exit 1 - fi - echo "Authentication token generated." - echo - echo "Determining coder version..." - export CODER_VERSION - CODER_VERSION=$(curl -fsSL "${CODER_URL}/api/v2/buildinfo" | jq -r .version | cut -d'+' -f1 | cut -d'v' -f2) - echo "Installing coder CLI..." - curl -fsSL https://coder.com/install.sh | sh -s -- --method standalone --version "${CODER_VERSION}" - echo - echo "Logging into Coder..." - coder login --use-token-as-session "${CODER_URL}" - - - name: Publish template - id: publish-template - env: - TEMPLATE_DIR: ${{ env.template_path }} - TEMPLATE_VERSION: "${{ needs.create-release.outputs.released_version != 'v0.0.0' && needs.create-release.outputs.released_version || needs.create-release.outputs.released_sha }}" - WORKSPACE_IMAGE: "${{ secrets.CONTAINER_REGISTRY }}/${{ vars.CONTAINER_REGISTRY_PATH }}/coder-workspace:${{ needs.publish-image.outputs.image_tag }}" - shell: bash - # yamllint disable-line rule:indentation - run: | - if echo "$TEMPLATE_VERSION" | grep -E '[0-9]+\.[0-9]+\.[0-9]+'; then - export TEMPLATE_NAME - TEMPLATE_NAME="$(echo "${TEMPLATE_DIR}" | cut -d/ -f3)" - export RELEASE_MSG="[Release Notes](https://github.com/${{ github.repository }}/releases/tag/${TEMPLATE_VERSION})" - export TEST_MODE=false - else - export TEMPLATE_NAME - TEMPLATE_NAME="$(echo "${TEMPLATE_DIR}" | cut -d/ -f3)-test" - export RELEASE_MSG="[Changes](https://github.com/${{ github.repository }}/commit/${TEMPLATE_VERSION})" - export TEST_MODE=true - fi - echo "Publishing template ${TEMPLATE_DIR} as ${TEMPLATE_NAME}..." - set -x - coder template push \ - --directory "${TEMPLATE_DIR}" \ - --var "workspace_image=${WORKSPACE_IMAGE}" \ - --var test_mode=${TEST_MODE} \ - --name "${TEMPLATE_VERSION}" \ - --message "${RELEASE_MSG}" \ - --yes \ - "${TEMPLATE_NAME}" - set +x - echo - echo "Confirming template has been published..." - coder templates list --output json > /tmp/templates.json - export SELECTED_TEMPLATE - SELECTED_TEMPLATE=$(cat /tmp/templates.json | jq -r '.[] | select(.Template.name == "'"${TEMPLATE_NAME}"'")') - if [[ -z $SELECTED_TEMPLATE ]]; then - echo "Could not find any template published as $TEMPLATE_NAME." - exit 1 - fi - echo "Template has been published successfully." - - - name: Tailscale Disconnect - if: success() || failure() - run: sudo -E tailscale logout diff --git a/.releaserc.js b/.releaserc.js deleted file mode 100644 index e8331581..00000000 --- a/.releaserc.js +++ /dev/null @@ -1,74 +0,0 @@ -const compareFunc = require('compare-func') - -module.exports = { - branches: ["main"], - plugins: [ - - ["@semantic-release/commit-analyzer", { - preset: "conventionalcommits", - releaseRules: [ - { type: "docs", release: "patch" }, - { type: "refactor", release: "patch" }, - { type: "style", release: "patch" }, - { type: "chore", release: "patch" } - ] - }], - - ["@semantic-release/release-notes-generator", { - preset: "conventionalcommits", - presetConfig: { - types: [ - { type: "build", hidden: true }, - { type: "chore", scope: "", section: "🐛 Enhancements + Bug Fixes" }, - { type: "chore", scope: "deps", section: "📌 Dependencies" }, - { type: "chore", scope: "other", hidden: true }, - { type: "ci", hidden: true }, - { type: "docs", section: "🛠 Improvements" }, - { type: "feat", section: "✨ Features" }, - { type: "fix", section: "🐛 Enhancements + Bug Fixes" }, - { type: "perf", section: "🐛 Enhancements + Bug Fixes" }, - { type: "refactor", section: "🐛 Enhancements + Bug Fixes" }, - { type: "revert", section: "⚙️ Other" }, - { type: "style", section: "🛠 Improvements" }, - { type: "test", section: "🛠 Improvements" } - ] - }, - writerOpts: { - groupBy: "type", - commitGroupsSort: (a, b) => { - const alphaA = a.title.replace(/[^a-zA-Z]/gu, ""); - const alphaB = b.title.replace(/[^a-zA-Z]/gu, ""); - - const commitGroupOrder = ['Features', 'EnhancementsBugFixes', 'Improvements', 'Other', 'Dependencies']; - const gRankA = commitGroupOrder.indexOf(alphaA); - const gRankB = commitGroupOrder.indexOf(alphaB); - return gRankA - gRankB; - }, - commitsSort: ['scope', 'subject'], - noteGroupsSort: 'title', - notesSort: compareFunc - } - }], - - ["@semantic-release/changelog", { - changelogFile: "CHANGELOG.md", - changelogTitle: "# Changelog\n\n" - }], - - ["@semantic-release/exec", { - prepareCmd: ` - set -e - - echo \${nextRelease.version} > /tmp/released.version; - ` - }], - - ["@semantic-release/git", { - assets: ["CHANGELOG.md"], - message: "chore(release): ${nextRelease.version} [skip ci]" - }], - - "@semantic-release/github" - ], - preset: "conventionalcommits" -}; diff --git a/CLAUDE.md b/CLAUDE.md index e292f4a2..06587971 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,13 +49,13 @@ Commitlint (`commitlint.config.js`) enforces Conventional Commits. ## Release flow -`.github/workflows/release.yaml` triggers on changes to `images/homelab-workspace/**`, `templates/**`, or the release workflow/config itself, and on merge to `main`: +`.github/workflows/release.yaml` runs release-please on every merge to `main`: -1. `semantic-release` (`.releaserc.js`) cuts a version from commit history, updates `CHANGELOG.md`. -2. The workspace image builds for `linux/amd64,linux/arm64` and pushes to the private registry. -3. The Terraform template pushes to the live Coder deployment via `coder template push`, tagged with the released version. +1. It creates or updates the release PR from Conventional Commit history. Merging that PR creates the GitHub release/tag and the next release PR. +2. The published GitHub release triggers `.github/workflows/publish.yaml`, which builds the workspace image for `linux/amd64,linux/arm64` and pushes it to the private registry. +3. The same publish workflow pushes the Terraform template to the live Coder deployment, tagged with the released version. -PRs and manual `workflow_dispatch` runs exercise this same pipeline in dry-run/test mode instead of publishing for real — see [TESTING.md](TESTING.md), which is the required reading before touching `templates/**` or `images/**`. +PRs affecting the image, template, or publish workflow exercise only `.github/workflows/publish.yaml` in test mode — no release simulation is needed. See [TESTING.md](TESTING.md), which is required reading before touching `templates/**` or `images/**`. ## Where things live diff --git a/README.md b/README.md index bf3808ed..4ff743b7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This repo is the middle of a larger stack: the Coder control plane is deployed s - **[DESIGN.md](DESIGN.md)** — why the template and image are built the way they are: trade-offs considered, decisions made, targeted outcomes. - **[TESTING.md](TESTING.md)** — how to validate a change, including exercising it against the real cluster without touching production workspace data. - **[CLAUDE.md](CLAUDE.md)** — commands and conventions for working in this repo with Claude Code. -- **[CHANGELOG.md](CHANGELOG.md)** — generated release history (semantic-release). +- **[CHANGELOG.md](CHANGELOG.md)** — generated release history (release-please). ## Contributing diff --git a/TESTING.md b/TESTING.md index 8ef75958..aac2f99c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,20 +8,20 @@ A change to `templates/**` or `images/**` isn't trustworthy just because it pars ## How it works -The release pipeline (`.github/workflows/release.yaml`) runs in one of two modes, gated by whether it's allowed to publish for real: +The publish pipeline (`.github/workflows/publish.yaml`) runs in one of two modes, gated by the event that starts it: -- **Dry-run mode** — automatic on any PR touching `images/**`, `templates/**`, or the release config itself, and available on demand via manual dispatch. -- **Live mode** — runs on merge to `main`. +- **Test mode** — automatic on any PR touching `images/**`, `templates/**`, or the publish workflow, and available on demand via manual dispatch. +- **Live mode** — runs only when release-please has published a GitHub release after its release PR is merged. -Both modes run the identical sequence of stages; only what each stage is permitted to do at the end differs. +Both modes run the identical image build and template-push sequence; only the release source and destination differ. Release coordination is separate: `.github/workflows/release.yaml` runs release-please on `main`, creating or updating its release PR. A PR test does not simulate that release step. ## What each stage confirms -1. **Versioning** — commit history since the last release is parsed to determine what the next version would be. In dry-run this stops short of tagging or publishing; it still confirms the commit history is well-formed enough to produce a valid release. -2. **Image build** — the container image is built for every published architecture and pushed to the registry. This is the same build a live release performs, so it confirms the Dockerfile still produces a working image end to end — dry-run only changes the tag it's pushed under, not the build itself. -3. **Template push** — the Terraform template is applied against the real Coder deployment and Kubernetes cluster, using the image just built. This confirms the template is actually valid against live provider/cluster state, not just internally consistent. Dry-run redirects this push to a separate, clearly-named template rather than the one real workspaces use, and backs it with disposable storage instead of the shared persistent volume — so nothing here can affect an existing workspace no matter what the change does. +1. **Release source** — test mode builds the PR branch or manually dispatched ref, and names the test template with that event's commit SHA. Live mode uses the GitHub release tag created by release-please for both. This keeps a test publish tied to the code under review without pretending that a release exists. +2. **Image build** — the container image is built for every published architecture and pushed to the registry. This is the same build a live release performs, so it confirms the Dockerfile still produces a working image end to end — test mode only changes the tag it is pushed under, not the build itself. +3. **Template push** — the Terraform template is applied against the real Coder deployment and Kubernetes cluster, using the image just built. This confirms the template is actually valid against live provider/cluster state, not just internally consistent. Test mode redirects this push to a separate, clearly-named template rather than the one real workspaces use, and backs it with disposable storage instead of the shared persistent volume — so nothing here can affect an existing workspace no matter what the change does. -Because all three stages run for real in dry-run — just scoped away from production — a passing PR is a meaningful signal that a live release would also succeed, not a guess based on static checks alone. +Because both publishing stages run for real in test mode — just scoped away from production — a passing PR is a meaningful signal that a live release would also succeed, not a guess based on static checks alone. ## The memory watchdog is the one part with runtime behaviour @@ -69,4 +69,4 @@ None of this proves the described drill did not happen — a drill run through t ## After merge -Merging to `main` is what flips the pipeline into live mode — there's no separate promotion step afterward. The dry-run pass on the PR is the actual release gate. +Merging an ordinary PR to `main` updates release-please's standing release PR; it does not publish artifacts. Merging that release PR creates the GitHub release, which flips the publish pipeline into live mode. The test-mode pass on the original PR is the actual artifact-release gate.