前言
我以前写 GitHub Actions 定时任务时,最容易出错的地方不是 Cron 表达式本身,而是时区换算。
比如想让任务在北京时间每个工作日早上 9 点执行,过去一般要换算成 UTC,再写成凌晨 1 点。一个人维护还好,团队里其他人看到 0 1 * * 1-5,很难马上判断这是北京时间 9 点,还是写错了凌晨任务。
Environment 也有类似问题。很多 CI 任务只是想复用某个环境里的 secrets 和 variables,比如集成测试要连 staging API、Terraform plan 要读取 production 配置。但只要 job 引用了 environment,就容易在部署历史里留下记录。时间一长,真正的部署和普通测试混在一起,部署记录就不干净了。
现在这两个地方都可以写得更直接。on.schedule 可以通过 timezone 按本地时区解释 Cron;environment 可以通过 deployment: false 只使用环境密钥和变量,不创建 deployment object。定时任务仍然运行在默认分支最新提交上,最短调度间隔还是 5 分钟;deployment: false 也不会绕过 wait timer 和 required reviewers。

一、定时任务终于可以按本地时间写
过去写 GitHub Actions 定时任务,默认按 UTC 解释。
假设我要在北京时间每个工作日上午 9 点跑报表,旧写法通常是:
yaml
on:
schedule:
- cron: '0 1 * * 1-5'
这段配置能跑,但可读性很差。你要在脑子里做一次 UTC+8 的换算,才能知道它其实对应北京时间 9 点。对于上海、纽约、伦敦这种跨时区团队,表达式会越来越难维护。
现在可以直接加 timezone:
yaml
name: Daily Report
on:
schedule:
- cron: '0 9 * * 1-5'
timezone: 'Asia/Shanghai'
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: ./scripts/generate-report.sh
这个配置读起来就很直观:工作日早上 9 点,按 Asia/Shanghai 执行。Cron 表达式仍然是五段 POSIX Cron,分别表示分钟、小时、日期、月份、星期。timezone 使用 IANA 时区字符串,比如 Asia/Shanghai、America/New_York、Europe/London。
这里有两个细节值得注意。
第一个是夏令时。像 America/New_York 这类会进入夏令时的时区,调度会按该时区规则处理。遇到春季跳过的时间段,比如 2:30 不存在,会顺延到下一个有效时间。国内常用的 Asia/Shanghai 没有这个问题,但海外团队要留意。
第二个是默认分支。定时任务跑的是默认分支上的最新提交,不是你某个 feature 分支上的 workflow 文件。你在分支里改了定时配置,如果还没合并到默认分支,不要期待它按新规则执行。
二、多时区任务要写清楚触发来源
一个 workflow 可以配置多个 schedule。这样很适合全球化团队,比如中国区、美国区、欧洲区各跑一份日报。
yaml
name: Regional Reports
on:
schedule:
- cron: '0 8 * * 1-5'
timezone: 'Asia/Shanghai'
- cron: '0 8 * * 1-5'
timezone: 'America/New_York'
- cron: '0 8 * * 1-5'
timezone: 'Europe/London'
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Generate regional report
run: ./scripts/generate-report.sh
这段配置有一个问题:三个 schedule 都会触发同一个 job,但 job 内部不知道当前是哪一个地区触发的。简单项目里可以接受,真实项目最好把触发来源判断清楚。
github.event.schedule 可以拿到当前触发的 Cron 字符串。可问题是这里三个 Cron 都是 0 8 * * 1-5,只靠它区分不出地区。所以多时区任务最好把 Cron 时间写出差异,或者拆成多个 workflow。
如果想保留在一个文件里,可以这样处理:
yaml
name: Regional Reports
on:
schedule:
- cron: '0 8 * * 1-5'
timezone: 'Asia/Shanghai'
- cron: '5 8 * * 1-5'
timezone: 'America/New_York'
- cron: '10 8 * * 1-5'
timezone: 'Europe/London'
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set region
id: region
shell: bash
run: |
case "${{ github.event.schedule }}" in
"0 8 * * 1-5")
echo "region=china" >> "$GITHUB_OUTPUT"
;;
"5 8 * * 1-5")
echo "region=us" >> "$GITHUB_OUTPUT"
;;
"10 8 * * 1-5")
echo "region=europe" >> "$GITHUB_OUTPUT"
;;
*)
echo "region=unknown" >> "$GITHUB_OUTPUT"
;;
esac
- name: Generate report
run: ./scripts/generate-report.sh "${{ steps.region.outputs.region }}"
我会更偏向把不同地区的 Cron 分开一点,避免后面定位问题时看不出是哪一条规则触发。虽然多条 schedule 可以放在一个 workflow 里,但一旦地区逻辑差异变大,拆成多个 workflow 会更清楚。
还要留意触发频率。定时任务最短只能 5 分钟跑一次。想做高频轮询、秒级调度、严格 SLA 的任务,GitHub Actions 不太适合当主调度器。它更适合日常报表、依赖检查、数据同步、清理任务这类工程自动化。
三、只想拿环境密钥,不一定要创建部署记录
Environment 原本更偏部署场景。比如 production environment 里放生产密钥、配置 required reviewers、加 wait timer,job 引用它后会产生部署记录。
这个行为对真正部署很合理:
yaml
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
steps:
- uses: actions/checkout@v6
- run: ./scripts/deploy.sh
问题出在非部署场景。
比如集成测试只是想拿 staging 的 API Key:
yaml
jobs:
integration-test:
runs-on: ubuntu-latest
environment:
name: staging
steps:
- uses: actions/checkout@v6
- run: npm run test:integration
这类 job 并没有部署 staging,却可能让环境历史里多出一条记录。时间久了,部署记录会被测试、扫描、检查任务污染。
现在可以这样写:
yaml
jobs:
integration-test:
runs-on: ubuntu-latest
environment:
name: staging
deployment: false
steps:
- uses: actions/checkout@v6
- name: Run integration tests
env:
API_KEY: ${{ secrets.API_KEY }}
API_BASE_URL: ${{ vars.API_BASE_URL }}
run: npm run test:integration
deployment: false 的作用很明确:job 仍然能访问 environment secrets 和 variables,但不会创建 deployment object,也不会更新该环境的部署历史。这个配置适合 CI、集成测试、安全扫描、Terraform plan、配置校验这些只需要环境配置、不执行真实部署的任务。
它不会绕过环境保护规则。wait timer 仍然会生效,required reviewers 仍然需要审批。也就是说,如果你把 staging 配成了必须审批,那么测试 job 即使写了 deployment: false,也还是要等审批。
这里最容易踩的坑是 custom deployment protection rules。它依赖 deployment object 工作,如果 environment 上配置了这类 GitHub App 保护规则,再写 deployment: false,job 会直接失败。遇到这种情况,要么去掉 deployment: false,让它按部署流程跑;要么把这类 CI 密钥放到一个没有 custom deployment protection rules 的 environment 里。
四、发布前检查可以复用环境配置,但不要污染部署历史
deployment: false 最适合放在发布前检查里。
比如一个生产发布流程,真正部署前要先做几件事:
检查数据库迁移是否可执行。
检查第三方服务配置是否完整。
跑一轮 smoke test。
生成发布说明或变更摘要。
这些步骤需要读取 production 或 release environment 里的配置,但它们本身还没有部署。可以把检查阶段和部署阶段拆开:
yaml
name: Release
on:
workflow_dispatch:
jobs:
preflight:
runs-on: ubuntu-latest
environment:
name: production
deployment: false
steps:
- uses: actions/checkout@v6
- name: Check release readiness
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_BASE_URL: ${{ vars.API_BASE_URL }}
run: ./scripts/check-release-readiness.sh
deploy:
runs-on: ubuntu-latest
needs: preflight
environment:
name: production
url: https://example.com
steps:
- uses: actions/checkout@v6
- name: Deploy production
run: ./scripts/deploy-production.sh
这个结构更清楚。
preflight 用 production 的 secrets 和 variables 做检查,但不写入部署历史。
deploy 才是真正的生产部署,会创建部署记录。
如果 production environment 配了 required reviewers,两个 job 都可能等待审批。想减少重复审批,可以单独建一个 production-checks environment,把发布前检查需要的只读密钥放进去,部署 job 继续使用 production environment。
yaml
jobs:
preflight:
runs-on: ubuntu-latest
environment:
name: production-checks
deployment: false
steps:
- uses: actions/checkout@v6
- run: ./scripts/check-release-readiness.sh
deploy:
runs-on: ubuntu-latest
needs: preflight
environment:
name: production
url: https://example.com
steps:
- uses: actions/checkout@v6
- run: ./scripts/deploy-production.sh
这种拆法在团队里更容易维护。检查环境放只读配置,部署环境放真正高权限密钥。权限边界清楚以后,deployment: false 的价值会更明显。
五、定时任务、Environment 和 concurrency 要一起考虑
定时任务一旦和环境配置结合,就要考虑并发。
比如每天早上 8 点按多个地区生成报表,如果前一次任务还没跑完,下一次又触发了,可能会同时写同一份报表、同一个对象存储路径,或者同时更新外部系统。
可以给同一地区加并发组:
yaml
name: Regional Scheduled Report
on:
schedule:
- cron: '0 8 * * 1-5'
timezone: 'Asia/Shanghai'
concurrency:
group: report-china
queue: max
jobs:
report:
runs-on: ubuntu-latest
environment:
name: reporting
deployment: false
steps:
- uses: actions/checkout@v6
- name: Generate report
env:
REPORT_BUCKET: ${{ vars.REPORT_BUCKET }}
REPORT_TOKEN: ${{ secrets.REPORT_TOKEN }}
run: ./scripts/generate-report.sh china
这里我会用 queue: max,让任务排队,而不是取消正在执行的任务。报表生成通常有副作用,直接取消可能留下半成品文件。如果只是普通 CI 检查,可以用 cancel-in-progress: true;如果会写外部系统,排队更稳。
如果一个 workflow 里有多个地区,可以把地区名放进并发组:
yaml
concurrency:
group: report-${{ inputs.region || 'scheduled' }}
queue: max
不过 schedule 触发时没有 inputs.region。如果地区判断来自 github.event.schedule,并发组也要跟着设计,不要让不同地区全部挤到同一个 report 组里。
这个地方有一个实用原则:哪个资源会互相抢,就按哪个资源设计并发组。多个地区写同一个外部系统,就用同一个组排队;多个地区写不同 bucket,就按地区分组。
总结
时区 Cron 解决的是定时任务可读性问题。过去要在本地时间和 UTC 之间来回换算,现在可以直接写 timezone: 'Asia/Shanghai'、timezone: 'America/New_York' 这类 IANA 时区字符串。对跨时区团队来说,这个改动能减少很多误解。
deployment: false 解决的是 environment 复用问题。CI、集成测试、发布前检查、Terraform plan 这些任务,经常需要环境 secrets 和 variables,但不应该出现在部署历史里。现在可以只拿环境配置,不创建 deployment object。
这两个能力都有边界。定时任务仍然跑默认分支最新提交,最短间隔还是 5 分钟。deployment: false 仍然会受 wait timer 和 required reviewers 影响,也不能和 custom deployment protection rules 一起用。
我的建议是先改两类 workflow。
第一类是定时任务。把原来需要手动换算 UTC 的 Cron 改成带 timezone 的本地时间表达,顺手检查是否需要 concurrency。
第二类是 CI 里引用 environment 的 job。只做测试、扫描、plan、preflight 的 job,加上 deployment: false,让部署历史只保留真正的部署。
这样改完以后,Actions 配置会更接近人的理解方式。定时任务按业务时间写,测试任务只拿配置,部署任务才进入部署历史。