GitHub Actions and the danger of invisible release power

Moving a personal release workflow into GitHub Actions made automation easier to see while making repository code capable of spending deployment authority.

When GitHub Actions became generally available in November 2019, the attractive part was not YAML. It was proximity.

The code, pull request, checks, workflow definition, and execution history could live in one place. Hosted runners covered Linux, macOS, and Windows. A matrix could exercise several runtime versions without maintaining machines. Actions could package repeatable steps. For a personal set of projects, that removed enough friction to be immediately useful.

It also collapsed boundaries I had previously treated as separate. A repository file could now cause a machine to receive credentials, build an artifact, and publish it. A green check looked like evidence about code quality while the same automation surface could hold release authority.

I migrated one small M31V workflow and discovered that the hard question was not how to write the steps. It was how to make the power visible.

The old workflow was awkward and legible

M31V already had a local release script. It installed locked dependencies, rendered content, hashed assets, ran checks, wrote a build manifest, and assembled an immutable release directory. Another deliberately separate command transferred the artifact and changed the active symlink.

The separation was inconvenient. I could forget to run a browser check or use a different local Node version. The laptop was a fragile build environment. Release history was split between Git, shell output, and the target host.

It was also obvious when deployment authority was present. I had to run a distinct command with credentials available in that shell. A build could not accidentally publish merely because it was being tested.

My first Actions draft erased that distinction:

on: [push, pull_request]
 
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - uses: actions/setup-node@v1
      - run: npm install
      - run: npm test
      - run: ./scripts/deploy

The example was short, demonstrable, and unsafe. Pull requests and ordinary branch pushes did not all deserve a deployment attempt. npm install could resolve newer transitive versions instead of proving the lockfile. The deploy script depended on ambient secrets and did not state which artifact it was publishing.

Automation had made the workflow more visible as text and less visible as authority.

I had initially reviewed workflow YAML like configuration: are the event names and indentation correct? That was too shallow.

A workflow could run shell commands, execute repository scripts, download Actions, start containers, and read credentials made available to the job. Changing one line could send an artifact elsewhere or print a secret through an apparently helpful diagnostic command.

I began reviewing .github/workflows with the same suspicion as a deployment script. A change needed to answer:

  • Which event grants execution?
  • Which revision of repository code will run?
  • Which third-party code executes on the runner?
  • Which credentials or tokens are present?
  • What external state can the job change?
  • Which artifact identity connects build to release?
  • What record proves the action completed?

The repository was personal; there was no invented release team or approval board. The risk still existed. A compromised dependency, careless copied Action, or future contribution could execute inside a context more powerful than the source code suggested.

“It is only CI” stopped being a meaningful reassurance once CI could deploy.

Build and release became separate jobs and separate triggers

The build workflow ran on pull requests and pushes. It received no deployment credential. Its purpose was to create evidence about a commit:

  1. Check out the intended revision.
  2. Select the declared runtime.
  3. Install exactly from the lockfile.
  4. Run unit, integration, content, and browser checks.
  5. Build the immutable artifact.
  6. Produce a manifest and checksums.

The release workflow ran only for a deliberately created version tag following the repository's release convention. It rebuilt from that tagged revision in the first iteration, then compared the resulting manifest with the expected inputs before publishing.

I would have preferred to promote the exact previously tested artifact. The early implementation did not yet have a storage path I trusted for durable cross-workflow promotion, so I treated rebuild reproducibility as an explicit limitation. If the release build's manifest differed from the validated candidate, it stopped.

The trigger was not an approval system. Anyone able to create the accepted tag effectively held release authority. In a one-person repository that boundary was manageable and documented. The target host still validated artifact shape and release identity rather than accepting arbitrary files.

Separating the workflows meant a pull request could prove build behavior without receiving the secret needed to change the site.

The script remained the product; YAML called it

My second draft translated every local build command into Actions steps. The workflow became a long platform-specific program that could be tested only by pushing commits.

I moved deterministic work back into repository scripts:

./scripts/verify
./scripts/build-release
./scripts/check-manifest

The Actions workflow selected the environment, invoked those entry points, retained useful output, and handled the GitHub event. The same scripts ran locally and in R7K1 previews.

This did not make local and hosted environments identical. The runner image, filesystem, network, and available tools still mattered. The build manifest recorded the runtime version, operating-system class, lockfile digest, build-script revision, and relevant tool versions.

Keeping workflow logic thin reduced feedback time. A broken manifest check could be exercised without making a parade of “fix CI” commits. It also limited lock-in. GitHub Actions provided orchestration and context; the build contract belonged to the project.

I allowed platform-specific steps where the platform genuinely owned the behavior, such as attaching check output or reading the event payload. I did not hide the release algorithm inside dozens of YAML expressions merely because they were available.

The first hosted build used npm install. It passed locally and failed later when a transitive resolution changed.

For projects with a committed lockfile and a compatible npm version, the workflow used the clean, lockfile-respecting installation command. It failed if package metadata and lock state disagreed. Dependency caching was optional acceleration; a cache hit could not alter the resolved dependency graph.

The manifest named the lockfile digest and selected Node version. A release built under an unexpected runtime was a different build, even if its tests happened to pass.

This repeated R7K1's lesson from 2016. Source commit alone did not identify an artifact. Build recipe, dependency state, runtime, and base environment contributed to the result.

Actions made matrix testing easy enough to check multiple supported Node versions. I used the matrix for compatibility evidence and chose one declared version for release output. Producing deployable artifacts from every matrix cell would create several candidates without a reason to prefer one.

A green matrix said the code passed the selected checks in those environments. It did not say their artifacts were byte-identical or that every supported runtime should publish.

Third-party Actions were dependencies with execution authority

The Marketplace made useful Actions easy to discover. A uses: line looked smaller and safer than a shell script. It could execute JavaScript or a container with the job's available context.

I limited the first workflow to a small set of official foundational Actions and repository-owned scripts. For any external Action, I inspected its source and inputs, understood what code the reference selected, and considered pinning to an immutable commit rather than a floating branch or movable tag.

Pinning created a maintenance obligation. Security fixes and runtime updates would not arrive automatically. Floating created a supply-chain obligation. New code could enter the release path without a repository diff. There was no reference choice that eliminated review.

I recorded the chosen revision and periodically revisited it. A convenience Action that only wrapped one readable command often lost to running the command directly. An Action that implemented nontrivial GitHub integration could justify the dependency if its authority and update path were clear.

The number of YAML lines saved was not a useful risk measure. What mattered was how much external code ran and what the job allowed it to do.

A pull request exists to run code that has not yet been accepted. Tests necessarily execute some of it. That makes the CI environment a boundary, not a trusted extension of the target branch.

The build job did not receive deployment credentials. It avoided credentials for unrelated services. Test fixtures replaced external side effects. Output retention was reviewed because logs and artifacts could expose data even when secrets were not intentionally printed.

I did not use a pull-request build to perform a release. I also did not create a privileged workflow that checked out and ran arbitrary contribution code merely so the result could post a more convenient status.

At the time, GitHub protected repository secrets from ordinary workflows triggered by forks, which was an important default. I did not treat it as the entire security model. A change merged into the default branch could alter later trusted execution. A dependency install script could run in both untrusted and trusted contexts. A malicious test could create an artifact that a poorly separated release later promoted.

The accepted revision boundary had to be explicit. Build evidence from a proposed revision was evidence, not permission for that revision to spend release authority.

Secrets were capabilities, not strings to hide

The first release secret could log in to the host and run several commands. It was broader than the workflow needed.

I narrowed the target interface. The release job could upload an immutable artifact under a digest, request activation of a validated release, query activation status, and receive a receipt. It could not open a general-purpose shell or overwrite arbitrary paths.

The host checked:

  • Artifact digest matches the request.
  • Manifest names an accepted repository and release revision.
  • Required checks and schema fields are present.
  • Release directory is new and immutable.
  • Activation changes one bounded route or symlink.
  • The previous release remains available for rollback.

The credential represented those operations. I treated its presence as authority to change the site, even if the raw value was masked in logs.

Masking protects accidental display. It does not stop a malicious process from using the credential to call its intended endpoint or encoding information through another channel. The secure design reduced what the credential could do and which jobs received it.

I also removed secrets from global environment configuration. A credential appeared only on the release step that needed it. That made the authority boundary more visible in review.

When the first hosted build failed, my instinct was to print the environment. That is exactly the sort of diagnostic command copied into a workflow and forgotten.

I added a safe diagnostic script that printed an allowlist of non-sensitive build facts: runtime version, operating-system label, working-directory structure, manifest input digests, and selected feature flags whose values were public. Unknown environment variables were not dumped.

Shell tracing was disabled around commands that handled credentials. Error messages reported response status and request identity without echoing authorization headers or full payloads. Uploaded artifacts excluded .env files, repository metadata not needed at runtime, and test output that could contain fixture secrets.

I tested failure paths with deliberately recognizable fake secret values and searched logs and artifacts for them. The test could not prove every future command safe, but it converted an invisible assumption into a regression check.

The lesson extended beyond Actions. Hosted automation encourages verbose logs because nobody can inspect the machine afterward. Logging needs more discipline, not less, when the environment contains capabilities.

Release identity crossed the workflow boundary

A release run had several identities available: the Git tag, commit SHA, workflow run, artifact digest, build manifest, and target release directory. My early logs used whichever one was convenient in the moment.

I made the artifact digest and manifest the center. The manifest linked source commit, tag, lockfile, build recipe, runtime, and generated asset map. The workflow run became provenance for how the artifact was produced. The target stored the digest and reported it from a small diagnostic endpoint.

Activation verification requested the public route and checked that reported release identity matched the intended artifact. A successful upload did not equal a visible release. A successful activation command did not equal observed convergence.

W93H ingested build, upload, activation-request, runtime-report, and rollback events separately. This made the new automation compatible with the incident evidence model rather than creating one opaque “GitHub deployment succeeded” marker.

The workflow summary linked to the target receipt. The target receipt linked back to the artifact identity. Either side could be inspected without trusting a name such as latest.

Concurrent releases needed a target-side invariant

Two tag events could start near each other. In 2019 I did not depend on later workflow-level concurrency features. The release target enforced serialization.

An activation request acquired a short lease against the route and named the currently active release it expected to replace. If another release had activated first, the compare-and-swap failed and required a fresh decision. Uploading immutable artifacts could occur concurrently; changing the active pointer could not.

This prevented a slower earlier run from overwriting a later release merely because its network step completed last.

The lease had an expiry and an operation ID. Retrying the same activation returned the existing receipt rather than toggling state again. Rollback was another explicit activation of a known prior artifact, not a reverse shell script trying to undo individual files.

Actions orchestrated the request. Correctness lived at the resource boundary where concurrent changes met.

This was a recurring pattern in the journey. A CI system can schedule work, but the system accepting a side effect must still defend its own invariants.

The finished workflow produced several statuses: verification, build, manifest validation, upload, activation, and post-activation observation. Combining them into one green check would make the result easy to scan and difficult to interpret.

I kept the stages visible and wrote their claims narrowly:

  • Verify passed: the selected revision passed the declared checks in the chosen environment.
  • Build passed: a manifest and artifact were produced from named inputs.
  • Upload passed: the target retained an artifact matching the digest.
  • Activation passed: the target accepted an atomic route change.
  • Observation passed: the public fixture reported the intended release identity and behavior.

None proved the release had no defects. They described the evidence the workflow had actually gathered.

When observation failed after activation, the workflow did not automatically roll back every time. Some failures could come from the observer or a dependency, and a blind rollback might make the situation worse. It recorded the failed condition, notified the quiet personal channel, and offered the known prior artifact as a bounded action. Safe automatic reversal remained scenario-specific.

Automation was most trustworthy when its claims were smaller than its confidence styling.

Convenience changed the threat model

GitHub Actions reduced real friction. The personal repositories gained repeatable hosted checks, visible histories, runtime matrices, and a release path that no longer depended on the laptop remaining configured exactly right.

The danger was not that the feature made releases automatic. The danger was that familiar repository mechanics could make release authority feel ordinary. A small YAML diff could change what code ran with which secret against which target.

I kept build logic local and reproducible, separated untrusted verification from trusted release, minimized external Actions, narrowed credentials, linked immutable identities, and enforced concurrency at the target. The result used only a modest part of the new platform.

That restraint was not fear of automation. It was recognition that automation moves decisions into code, where they execute quickly and repeatedly.

The most important line in a workflow is not always the command. Sometimes it is the event that grants the run, the step that receives a credential, or the target that decides whether the requested side effect is allowed.

Putting CI beside the code made the process easier to see. It also made the repository part of the release control plane. I wanted every green check to show which claim had passed—and every powerful step to look as powerful as it was.