Reading Old GitHub Actions Workflows: From set-output to GITHUB_OUTPUT, From Node 12/16/20 to Node 24
When updating an old workflow, change the runner label, action majors, SDK source, workflow commands and cache key together: ubuntu-18.04, @v2 actions and ::set-output either no longer work or warn in 2026. A 2018–2026 timeline, the Node 12→24 transitions, actionlint and act output for a 2021-style .NET 5 workflow, a checklist for moving to .NET 10 and a summary of 2026.
Version note: The examples in this post were run with act 0.2.97, actionlint 1.7.12, Docker 29.2, .NET 5.0.408 and .NET 10 (SDK 10.0.403). Dates were checked against the GitHub changelog and the actions' release notes as of November 2026; the old workflow was not run on GitHub.
Guess First
You copy a 2021 workflow written for .NET 5 from a StackOverflow answer: runs-on: ubuntu-18.04, actions/checkout@v2, actions/setup-dotnet@v1, actions/cache@v2, upload-artifact@v2 and a step that produces the version number with echo "::set-output name=value::...". When you push it to GitHub in November 2026, where does it break first?
Second question: the ::set-output line in the same file was declared deprecated in October 2022. Does it work today?
Why Learn to Read Old Workflows?
Most GitHub Actions examples on the internet were written between 2019 and 2023; blog posts, StackOverflow answers, internal wikis and probably the first workflow file in your own repository come from that period. Actions changed fast in that time: workflow commands became file-based, the action runtime changed Node versions four times, and old versions of the artifact and cache actions as well as several runner images were shut down entirely. Being able to tell, while reading an old file, what is still valid, what warns and what no longer works at all is the last step out of the copy-paste level.
Timeline: From 2018 to 2026
| Date | Change |
|---|---|
| October 2018 | Actions announced as a beta; workflows written in HCL (HashiCorp Configuration Language) in a main.workflow file |
| August 2019 | YAML (YAML Ain't Markup Language) syntax and support for CI (Continuous Integration) and CD (Continuous Delivery); HCL workflows stopped running on September 30, 2019 |
| November 13, 2019 | GA (General Availability) |
| November 16, 2020 | set-env and add-path commands disabled because of a vulnerability |
| September 2022 – June 2023 | JavaScript actions move from Node 12 to Node 16 |
| October 11, 2022 | set-output and save-state deprecated; removal postponed indefinitely in July 2023 |
| April 2023 | ubuntu-18.04 runner image removed |
| September 2023 – June 3, 2024 | From Node 16 to Node 20 |
| January 30, 2025 | upload/download-artifact v1–v3 shut down |
| March 1, 2025 | actions/cache v1–v2 and the legacy cache service shut down |
| April 15, 2025 | ubuntu-20.04 runner image removed; windows-2019 on June 30, 2025 |
| June 16, 2026 | Node 24 becomes the default |
| September 23, 2026 | Node 20 removed from runners, and the opt-out closed too |
Every row in the table corresponds to a concrete line in an old workflow. Let's read them one by one.
Workflow Commands: From ::set-output to Files
In the early days, steps talked to the runner by writing specially formatted lines to stdout: ::set-output, ::save-state, ::set-env, ::add-path. The problem: anything that could be printed to the log could be a command. A ::set-env line embedded in a test's output or in a PR (pull request) title could change the runner's environment. That is why set-env and add-path were disabled in November 2020, and set-output and save-state were deprecated in October 2022. They were replaced by files the runner provides for every step:
# Old (before 2022)
echo "::set-output name=value::1.0.42"
echo "::set-env name=DOTNET_ROOT::/root/.dotnet"
echo "::add-path::/root/.dotnet/tools"
echo "::save-state name=started::true"
# Today
echo "value=1.0.42" >> "$GITHUB_OUTPUT"
echo "DOTNET_ROOT=/root/.dotnet" >> "$GITHUB_ENV"
echo "/root/.dotnet/tools" >> "$GITHUB_PATH"
echo "started=true" >> "$GITHUB_STATE"
For a multi-line value you use the delimiter form, as we saw in post 03:
{
echo "notes<<EOF"
cat release-notes.md
echo "EOF"
} >> "$GITHUB_OUTPUT"
The answer to the second question: set-output still works in November 2026. GitHub postponed the removal date indefinitely in July 2023 and has not announced a new date since; the command keeps producing a warning annotation. This is the difference between "works" and "should be used"; actionlint flags it explicitly. set-env and add-path, on the other hand, have been off since 2020; they can only be turned back on with ACTIONS_ALLOW_UNSECURE_COMMANDS, which is as unsafe as its name says.
Action Runtimes: From Node 12 to Node 24
A JavaScript action declares which Node version it runs on with runs.using in its action.yml: node12, node16, node20, node24. As Node versions reached end of life, GitHub forced this field forward: in June 2023 Node 12 actions started running on Node 16, and on June 3, 2024 Node 16 actions started running on Node 20. The latest switch was announced in September 2025; Node 24 became the default on June 16, 2026 and Node 20 was removed from runners entirely on September 23, 2026. During the transition, FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true allowed switching early and ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true allowed a temporary fallback; the latter is gone now.
In practice this means a line like actions/checkout@v2 tries to run code written for Node 12 on Node 24 today. Simple actions often work, but there is no guarantee, and none of them receive security updates anymore. Actions using Node 24 (checkout@v5 and later, setup-dotnet@v5 and later) require at least version 2.327.1 on self-hosted runners; Node 24 does not run on macOS 13.4 and earlier, and is not officially supported on 32-bit Arm processors.
The major versions of the actions we used in the series carry the traces of this history:
- actions/checkout: v2 (December 2019), v3 (Node 16, March 2022), v4 (Node 20, October 2023), v5 (Node 24, August 2025), v6 (credentials under
$RUNNER_TEMPinstead of.git/config, November 2025), v7 (does not check out fork code underpull_request_targetby default, June 2026). - actions/setup-dotnet: v2 (Node 16, 2022), v3 (
dotnet-qualityinput, September 2022), v4 (Node 20, December 2023), v5 (Node 24, September 2025), v6 (ESM (ECMAScript Modules) structure, no input changes, July 2026).
Reading an Old Workflow: 2021-Style .NET 5
The workflow from the guess, very close to the templates of that era:
name: .NET
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: 5.0.x
- uses: actions/cache@v2
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
- name: Restore dependencies
run: dotnet restore api/Bookstore.slnx
- name: Build
run: dotnet build api/Bookstore.slnx --no-restore
- name: Version
id: version
run: echo "::set-output name=value::1.0.${{ github.run_number }}"
- name: Publish
run: dotnet publish api/src/Bookstore.Api -c Release -o out /p:Version=${{ steps.version.outputs.value }}
- uses: actions/upload-artifact@v2
with:
name: api
path: out
actionlint's verdict is six lines:
.github/workflows/dotnet.yml:12:14: label "ubuntu-18.04" is unknown. available labels are ... [runner-label]
.github/workflows/dotnet.yml:15:13: the runner of "actions/checkout@v2" action is too old to run on GitHub Actions. update the action's version to fix this issue [action]
.github/workflows/dotnet.yml:17:13: the runner of "actions/setup-dotnet@v1" action is too old to run on GitHub Actions. update the action's version to fix this issue [action]
.github/workflows/dotnet.yml:20:13: the runner of "actions/cache@v2" action is too old to run on GitHub Actions. update the action's version to fix this issue [action]
.github/workflows/dotnet.yml:30:12: workflow command "set-output" was deprecated. use `echo "{name}={value}" >> $GITHUB_OUTPUT` instead: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions [deprecated-commands]
.github/workflows/dotnet.yml:33:13: the runner of "actions/upload-artifact@v2" action is too old to run on GitHub Actions. update the action's version to fix this issue [action]
The answer to the first question is in the first line: on GitHub this workflow never reaches a single step, because the ubuntu-18.04 image was removed in April 2023 and there is no runner to take the job. Even if you fix the label, actions/cache@v2 and upload-artifact@v2 fail because their services were shut down in 2025.
More interesting is what happens under act, because act doesn't know about these retirements. I mapped the label to the act image and ran it in the Bookstore repository:
[.NET/build] ✅ Success - Main actions/checkout@v2 [40.617166ms]
[.NET/build] 🚧 ::warning::.NET 5.0 is no longer supported and will not receive security updates in the future. Please refer to https://aka.ms/dotnet-core-support for more information about the .NET support policy.
[.NET/build] ✅ Success - Main Setup .NET [50.567496375s]
[.NET/build] | Cache not found for input keys: Linux-nuget-acfe4c79a7074611f247e6e9515208b813c4185ab3ea5fbb15a3171029bcce3a
[.NET/build] ✅ Success - Main actions/cache@v2 [1.101288208s]
[.NET/build] ⭐ Run Main Restore dependencies
[.NET/build] | 5.0.408 [/root/.dotnet/sdk]
[.NET/build] | Could not execute because the application was not found or a compatible .NET SDK is not installed.
[.NET/build] | * You intended to execute a .NET SDK command:
[.NET/build] | A compatible installed .NET SDK for global.json version [10.0.400] from [.../global.json] was not found.
[.NET/build] ❌ Failure - Main Restore dependencies [58.395042ms]
Under act, all the old actions worked: setup-dotnet@v1 installed .NET 5.0.408 (with a warning that it is out of support), and cache@v2 talked to act's local cache server. What broke it was the repository itself: global.json asks for SDK 10.0.400, and only 5.0.408 is on the machine. The lesson goes both ways: passing under act is not passing on GitHub (the v2 cache and artifact services don't exist there), and when you copy an old workflow into a new project, every link of the toolchain has to be updated together.
Moving to .NET 10 Step by Step
The same job as it looks today:
name: api-ci
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-test:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- name: Restore
run: dotnet restore api/Bookstore.slnx --locked-mode
- name: Build
run: dotnet build api/Bookstore.slnx --no-restore -c Release
- name: Test
run: dotnet test --project api/tests/Bookstore.Api.Tests --no-build -c Release
- name: Version
id: version
run: echo "value=1.0.${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
- name: Publish
env:
VERSION: ${{ steps.version.outputs.value }}
run: dotnet publish api/src/Bookstore.Api -c Release -o out -p:Version="$VERSION"
- uses: actions/upload-artifact@v7
with:
name: api-publish
path: out
retention-days: 7
actionlint passed it cleanly; the act run (with the artifact version change from post 09):
[api-ci/build-test] ✅ Success - Main actions/setup-dotnet@v6 [13.392097084s]
[api-ci/build-test] | SDK Version: 10.0.403
[api-ci/build-test] ✅ Success - Main Restore [5.122548333s]
[api-ci/build-test] ✅ Success - Main Build [1.774625542s]
[api-ci/build-test] ✅ Success - Main Test [1.088162333s]
[api-ci/build-test] ✅ Success - Main Version [58.091334ms]
[api-ci/build-test] ⚙ ::set-output:: value=1.0.1
[api-ci/build-test] ✅ Success - Main Publish [1.00435875s]
[api-ci/build-test] 🏁 Job succeeded
Watch out for one source of confusion: act shows the value written to $GITHUB_OUTPUT in its own log in the ::set-output:: form. That's just act's display format; the workflow does not use the deprecated command. Turning the changes I made during the migration into a checklist:
- Runner label: from a retired image to a pinned current image (
ubuntu-24.04), aware of the migration schedule from post 14. - Action versions: every
uses:line to the current major, and in a production repository with a full commit SHA (Secure Hash Algorithm) (post 13). - SDK:
global-json-fileinstead ofdotnet-version: 5.0.x; the single source of the SDK version is theglobal.jsonin the repository. - Restore:
--locked-modeand a lock file; the cache key from the lock file's hash instead of*.csproj(post 15). - Workflow commands:
$GITHUB_OUTPUTinstead of::set-output;env:instead of embedding expressions inrun:(post 13). - Write defaults explicitly:
permissions: contents: read,concurrency,timeout-minutes,persist-credentials: false, artifactretention-days. - Branch name: the repository's real default branch instead of
master; a workflow that never triggers doesn't error, it silently never runs.
2026 at a Glance: What Changed This Year?
Throughout the series we touched on the changes of 2026 as they came up. Seeing them together means recognizing the next generation of "old workflows" today:
In January, hosted runner prices dropped (14). In March came deployment: false for environments and timezone for cron (09). In April, service containers got entrypoint and command, and OIDC (OpenID Connect) tokens got repository custom property claims; the immutable subject format with IDs, announced the same month, became the default for repositories created after July 15 (10). In May, concurrency groups got queue: max (09). In June, actions/checkout@v7 stopped checking out fork code in privileged events, untrusted events started getting read-only cache tokens and Node 24 became the default (13, 15). In July came the $/ syntax for actions in the same repository (12). In September, Ubuntu 26.04 runners became GA and the ubuntu-latest migration began, workflow execution protections became GA, and Node 20 was removed (13, 14). In October, workflow lockfiles went to preview and then to GA at the end of the month, and runners started running on AWS (Amazon Web Services) too (13, 14). Next month, on December 2, 2026, pull_request_target will be blocked by default in public repositories (13).
The common direction is clear: less implicit trust. A locked commit instead of a tag, an ID instead of a name, explicitly requested permissions instead of default write access, a policy instead of a door open to every event.
In Production
Manage workflow files like dependencies. The trio of Dependabot's github-actions ecosystem, lockfiles and actionlint keeps an old action from silently staying around for years.
Follow the changelog. Every break in this post was announced months ahead in the GitHub changelog and often as a warning annotation in the workflow log. A team that doesn't read annotations sees the break for the first time on the day it breaks.
Take brownouts seriously. Before big shutdowns GitHub runs short, scheduled outages (for the cache service in February 2025, for pull_request_target in November 2026). A workflow that turns red during a brownout is the workflow that will break on shutdown day.
Don't be afraid to delete old workflows. Files nobody looks at, bound to master and never triggered, leave nothing in the repository but confusion and attack surface.
Common Pitfalls
Copying an old answer without updating the version numbers. The @v2s, ubuntu-18.04 and dotnet-version: 5.0.x come together.
Leaving a command that warns because "it works". set-output works today; when a removal date is announced you'll have a backlog of work waiting.
Assuming an old workflow that passes under act will pass on GitHub. act doesn't know about shut-down services and removed images.
Updating only the major tag without reading the behavior changes. In checkout v6 the location of credentials changed, in v7 the fork PR behavior; read the release notes when you jump versions.
A cache key with hashFiles('**/*.csproj'). With floating package versions, the same key maps to different content.
A master/main mismatch. A workflow with the wrong branch filter never runs, and nobody notices.
A Small Experiment
★☆☆ Run the oldest workflow file in one of your repositories through actionlint and match the findings to the rows of the timeline in this post.
★★☆ Run the old .NET workflow under act on a .NET 5 sample project without a global.json, then move it to .NET 10 step by step using this post's checklist; compare act and actionlint output after each step.
★★★ Write a single "workflow health" job for all the workflows in your repository: it should run actionlint, zizmor and gh actions-lock --verify, report the results as a table in the job summary and run weekly on a schedule.
The GitHub Actions series ends here. We started with workflows, triggers, expressions and jobs; built real CI pipelines for .NET and Angular; deployed with environments, OIDC, SSH (Secure Shell) and GHCR (GitHub Container Registry); reduced duplication, locked down the supply chain, and got to know runners and cost. If you want to keep going, the topics we deliberately kept short in this series are the subject of upcoming series: the logic of blue-green and canary in the Deployment Strategies series, moving containers into a cluster in the Kubernetes series, and the same pipeline thinking in another tool in the Jenkins series.